refactor: convert masquerade UI widgets to Function Components + TypeScript (#1513)
* refactor: convert masquerade UI widgets to TypeScript * test: improve test coverage * chore: upgrade @testing-library/user-event to v14 * test: improve test coverage * test: improve test coverage
This commit is contained in:
@@ -135,6 +135,7 @@ describe('DatesTab', () => {
|
||||
});
|
||||
|
||||
it('shows extra info', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { items } = await getDay('Sat, Aug 17, 2030');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
@@ -142,10 +143,12 @@ describe('DatesTab', () => {
|
||||
const tipText = "ORA Dates are set by the instructor, and can't be changed";
|
||||
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
await user.hover(tipIcon);
|
||||
screen.getByText(tipText); // now it's there
|
||||
await user.unhover(tipIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // and it's gone again
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand all' });
|
||||
@@ -153,11 +154,11 @@ describe('Outline Tab', () => {
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
await user.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
await user.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
});
|
||||
|
||||
@@ -275,16 +276,17 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('renders show more/less button and handles click', async () => {
|
||||
const user = userEvent.setup();
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showMoreButton);
|
||||
await user.click(showMoreButton);
|
||||
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showLessButton);
|
||||
await user.click(showLessButton);
|
||||
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).not.toBeInTheDocument();
|
||||
showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('<CourseOutlineTray />', () => {
|
||||
});
|
||||
|
||||
it('collapses sidebar correctly when toggle button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore();
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar });
|
||||
@@ -94,33 +95,36 @@ describe('<CourseOutlineTray />', () => {
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
|
||||
userEvent.click(collapseBtn);
|
||||
await user.click(collapseBtn);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('toggles openSequenceId correctly when a sequence is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
const sequenceButton = screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` });
|
||||
expect(sequenceButton).toBeInTheDocument();
|
||||
userEvent.click(sequenceButton);
|
||||
await user.click(sequenceButton);
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toHaveAttribute('aria-expanded', 'true');
|
||||
userEvent.click(sequenceButton);
|
||||
await user.click(sequenceButton);
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('updates setOpenSequenceId correctly when toggling sequences', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
const sequenceButton = screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` });
|
||||
expect(sequenceButton).toBeInTheDocument();
|
||||
userEvent.click(sequenceButton);
|
||||
await user.click(sequenceButton);
|
||||
expect(sequenceButton).toHaveAttribute('aria-expanded', 'true');
|
||||
userEvent.click(sequenceButton);
|
||||
await user.click(sequenceButton);
|
||||
expect(sequenceButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('navigates to section or sequence level correctly on click by back/section button', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
@@ -128,11 +132,11 @@ describe('<CourseOutlineTray />', () => {
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toBeInTheDocument();
|
||||
|
||||
userEvent.click(sidebarBackBtn);
|
||||
await user.click(sidebarBackBtn);
|
||||
expect(sidebarBackBtn).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.courseOutlineTitle.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: `${section.title} , ${courseOutlineMessages.incompleteSection.defaultMessage}` }));
|
||||
await user.click(screen.getByRole('button', { name: `${section.title} , ${courseOutlineMessages.incompleteSection.defaultMessage}` }));
|
||||
expect(screen.queryByRole('button', { name: section.title })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('<CourseOutlineTrigger />', () => {
|
||||
}
|
||||
|
||||
it('renders correctly for desktop when sidebar is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar }, { isMobileView: false });
|
||||
@@ -52,13 +53,14 @@ describe('<CourseOutlineTrigger />', () => {
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('renders correctly for mobile when sidebar is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
@@ -71,13 +73,14 @@ describe('<CourseOutlineTrigger />', () => {
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('changes current sidebar value on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
@@ -91,7 +94,7 @@ describe('<CourseOutlineTrigger />', () => {
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('<SidebarSection />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly when section is incomplete', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
|
||||
@@ -44,12 +45,13 @@ describe('<SidebarSection />', () => {
|
||||
expect(container.querySelector('.text-success')).not.toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
userEvent.click(button);
|
||||
await user.click(button);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledWith(section.id);
|
||||
});
|
||||
|
||||
it('renders correctly when section is complete', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
const { getByText, getByTestId } = render(
|
||||
<RootWrapper section={{ ...section, completionStat: { completed: 4, total: 4 }, complete: true }} />,
|
||||
@@ -60,7 +62,7 @@ describe('<SidebarSection />', () => {
|
||||
expect(getByTestId('check-circle-icon')).toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
userEvent.click(button);
|
||||
await user.click(button);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledWith(section.id);
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('<SidebarSequence />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly when sequence is not collapsed and complete', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
renderWithProvider({
|
||||
defaultOpen: true,
|
||||
@@ -67,6 +68,6 @@ describe('<SidebarSequence />', () => {
|
||||
expect(screen.getByText(sequence.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(sequenceDescription)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.completedAssignment.defaultMessage}`)).toBeInTheDocument();
|
||||
userEvent.click(screen.getByText(sequence.title));
|
||||
await user.click(screen.getByText(sequence.title));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('<SidebarUnit />', () => {
|
||||
});
|
||||
|
||||
it('sends log event correctly when unit is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await initTestStore();
|
||||
renderWithProvider({ unit: { ...unit } });
|
||||
const logData = {
|
||||
@@ -99,7 +100,7 @@ describe('<SidebarUnit />', () => {
|
||||
widget_placement: 'left',
|
||||
};
|
||||
|
||||
userEvent.click(screen.getByText(unit.title));
|
||||
await user.click(screen.getByText(unit.title));
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', logData);
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', logData);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React, {
|
||||
Component,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Input } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
class MasqueradeUserNameInput extends Component {
|
||||
onError(...args) {
|
||||
return this.props.onError(...args);
|
||||
}
|
||||
|
||||
onKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
return this.onSubmit(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onSubmit(event) {
|
||||
const payload = {
|
||||
role: 'student',
|
||||
user_name: event.target.value,
|
||||
};
|
||||
this.props.onSubmit(payload).then((data) => {
|
||||
if (data && data.success) {
|
||||
global.location.reload();
|
||||
} else {
|
||||
const error = (data && data.error) || '';
|
||||
this.onError(error);
|
||||
}
|
||||
}).catch(() => {
|
||||
const message = this.props.intl.formatMessage(messages.genericError);
|
||||
this.onError(message);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
intl,
|
||||
onError,
|
||||
onSubmit,
|
||||
...rest
|
||||
} = this.props;
|
||||
return (
|
||||
<Input
|
||||
aria-labelledby="masquerade-search-label"
|
||||
label={intl.formatMessage(messages.userNameLabel)}
|
||||
onKeyPress={(event) => this.onKeyPress(event)}
|
||||
type="text"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
MasqueradeUserNameInput.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
export default injectIntl(MasqueradeUserNameInput);
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Input } from '@openedx/paragon';
|
||||
|
||||
import { MasqueradeStatus, Payload } from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onSubmit' | 'onError'> {
|
||||
onError: (error: string) => void;
|
||||
onSubmit: (payload: Payload) => Promise<MasqueradeStatus>;
|
||||
}
|
||||
|
||||
export const MasqueradeUserNameInput: React.FC<Props> = ({ onSubmit, onError, ...otherProps }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSubmit = React.useCallback((userIdentifier: string) => {
|
||||
const payload: Payload = {
|
||||
role: 'student',
|
||||
user_name: userIdentifier, // user name or email
|
||||
};
|
||||
onSubmit(payload).then((data) => {
|
||||
if (data && data.success) {
|
||||
global.location.reload();
|
||||
} else {
|
||||
const error = (data && data.error) || '';
|
||||
onError(error);
|
||||
}
|
||||
}).catch(() => {
|
||||
const message = intl.formatMessage(messages.genericError);
|
||||
onError(message);
|
||||
});
|
||||
return true;
|
||||
}, [onError]);
|
||||
|
||||
const handleKeyPress = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
return handleSubmit(event.currentTarget.value);
|
||||
}
|
||||
return true;
|
||||
}, [handleSubmit]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
aria-labelledby="masquerade-search-label"
|
||||
label={intl.formatMessage(messages.userNameLabel)}
|
||||
onKeyPress={handleKeyPress}
|
||||
type="text"
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, {
|
||||
Component,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
|
||||
import { UserMessagesContext } from '../../generic/user-messages';
|
||||
import MasqueradeUserNameInput from './MasqueradeUserNameInput';
|
||||
import MasqueradeWidgetOption from './MasqueradeWidgetOption';
|
||||
import {
|
||||
getMasqueradeOptions,
|
||||
postMasqueradeOptions,
|
||||
} from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
class MasqueradeWidget extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.courseId = props.courseId;
|
||||
this.state = {
|
||||
autoFocus: false,
|
||||
masquerade: this.props.intl.formatMessage(messages.titleStaff),
|
||||
active: {},
|
||||
available: [],
|
||||
shouldShowUserNameInput: false,
|
||||
masqueradeUsername: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getMasqueradeOptions(this.courseId).then((data) => {
|
||||
if (data.success) {
|
||||
this.onSuccess(data);
|
||||
} else {
|
||||
// This was explicitly denied by the backend;
|
||||
// assume it's disabled/unavailable.
|
||||
// eslint-disable-next-line no-console
|
||||
this.onError('Unable to get masquerade options');
|
||||
}
|
||||
}).catch((response) => {
|
||||
// There's not much we can do to recover;
|
||||
// if we can't fetch masquerade options,
|
||||
// assume it's disabled/unavailable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unable to get masquerade options', response);
|
||||
});
|
||||
}
|
||||
|
||||
onError(message) {
|
||||
this.props.onError(message);
|
||||
}
|
||||
|
||||
async onSubmit(payload) {
|
||||
this.clearError();
|
||||
const options = await postMasqueradeOptions(this.courseId, payload);
|
||||
return options;
|
||||
}
|
||||
|
||||
onSuccess(data) {
|
||||
const { active, available } = this.parseAvailableOptions(data);
|
||||
this.setState({
|
||||
active,
|
||||
available,
|
||||
});
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
const options = this.state.available.map((group) => (
|
||||
<MasqueradeWidgetOption
|
||||
groupId={group.groupId}
|
||||
groupName={group.name}
|
||||
key={group.name}
|
||||
role={group.role}
|
||||
selected={this.state.active}
|
||||
userName={group.userName}
|
||||
userPartitionId={group.userPartitionId}
|
||||
userNameInputToggle={(...args) => this.toggle(...args)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
/>
|
||||
));
|
||||
return options;
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.props.onError('');
|
||||
}
|
||||
|
||||
toggle(show, groupId, groupName, role, userName, userPartitionId) {
|
||||
this.setState(prevState => ({
|
||||
autoFocus: true,
|
||||
masquerade: groupName,
|
||||
shouldShowUserNameInput: show === undefined ? !prevState.shouldShowUserNameInput : show,
|
||||
active: {
|
||||
...prevState.active, groupId, role, userName, userPartitionId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
parseAvailableOptions(postData) {
|
||||
const data = postData || {};
|
||||
const active = data.active || {};
|
||||
const available = data.available || [];
|
||||
if (active.userName) {
|
||||
this.setState({
|
||||
autoFocus: false,
|
||||
masquerade: 'Specific Student...',
|
||||
masqueradeUsername: active.userName,
|
||||
shouldShowUserNameInput: true,
|
||||
});
|
||||
} else if (active.groupName) {
|
||||
this.setState({ masquerade: active.groupName });
|
||||
} else if (active.role === 'student') {
|
||||
this.setState({ masquerade: 'Learner' });
|
||||
}
|
||||
return { active, available };
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoFocus,
|
||||
masquerade,
|
||||
shouldShowUserNameInput,
|
||||
masqueradeUsername,
|
||||
} = this.state;
|
||||
const specificLearnerInputText = this.props.intl.formatMessage(messages.placeholder);
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<div className="row">
|
||||
<span className="col-auto col-form-label pl-3"><FormattedMessage {...messages.titleViewAs} /></span>
|
||||
<Dropdown className="flex-shrink-1 mx-1">
|
||||
<Dropdown.Toggle id="masquerade-widget-toggle" variant="inverse-outline-primary">
|
||||
{masquerade}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{this.getOptions()}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{shouldShowUserNameInput && (
|
||||
<div className="row mt-2">
|
||||
<span className="col-auto col-form-label pl-3" id="masquerade-search-label">{`${specificLearnerInputText}:`}</span>
|
||||
<MasqueradeUserNameInput
|
||||
id="masquerade-search"
|
||||
className="col-4"
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={masqueradeUsername}
|
||||
onError={(errorMessage) => this.onError(errorMessage)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
MasqueradeWidget.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
};
|
||||
MasqueradeWidget.contextType = UserMessagesContext;
|
||||
export default injectIntl(MasqueradeWidget);
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getAllByRole } from '@testing-library/dom';
|
||||
import { act } from '@testing-library/react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MasqueradeWidget from './MasqueradeWidget';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, logUnhandledRequests,
|
||||
} from '../../setupTest';
|
||||
|
||||
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
getConfig.mockImplementation(() => originalConfig);
|
||||
|
||||
describe('Masquerade Widget Dropdown', () => {
|
||||
let mockData;
|
||||
let courseware;
|
||||
let mockResponse;
|
||||
let axiosMock;
|
||||
let masqueradeUrl;
|
||||
const masqueradeOptions = [
|
||||
{
|
||||
name: 'Staff',
|
||||
role: 'staff',
|
||||
},
|
||||
{
|
||||
name: 'Specific Student...',
|
||||
role: 'student',
|
||||
user_name: '',
|
||||
},
|
||||
{
|
||||
group_id: 1,
|
||||
name: 'Audit',
|
||||
role: 'student',
|
||||
user_partition_id: 50,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore();
|
||||
courseware = store.getState().courseware;
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
|
||||
mockData = {
|
||||
courseId: courseware.courseId,
|
||||
onError: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active: {
|
||||
course_key: courseware.courseId,
|
||||
group_id: null,
|
||||
role: 'staff',
|
||||
user_name: null,
|
||||
user_partition_id: null,
|
||||
group_name: null,
|
||||
},
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
it('renders masquerade name correctly', async () => {
|
||||
render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Staff');
|
||||
});
|
||||
|
||||
masqueradeOptions.forEach((option) => {
|
||||
it(`marks role ${option.role} as active`, async () => {
|
||||
const active = {
|
||||
course_key: courseware.courseId,
|
||||
group_id: option.group_id ?? null,
|
||||
role: option.role,
|
||||
user_name: option.user_name ?? null,
|
||||
user_partition_id: option.user_partition_id ?? null,
|
||||
group_name: null,
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active,
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
await act(async () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === option.name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the clicks with toggle', async () => {
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
await act(async () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
const studentOption = getAllByRole(dropdownMenu, 'button', { hidden: true }).filter(
|
||||
button => (button.textContent === 'Specific Student...'),
|
||||
)[0];
|
||||
await act(async () => {
|
||||
await fireEvent.click(studentOption);
|
||||
});
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === 'Specific Student...') {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { MasqueradeWidget } from './MasqueradeWidget';
|
||||
import {
|
||||
fireEvent,
|
||||
getAllByRole,
|
||||
initializeTestStore,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '../../setupTest';
|
||||
|
||||
describe('Masquerade Widget Dropdown', () => {
|
||||
let mockData;
|
||||
let courseware;
|
||||
let mockResponse;
|
||||
let axiosMock: MockAdapter;
|
||||
let masqueradeUrl: string;
|
||||
const masqueradeOptions = [
|
||||
{
|
||||
name: 'Staff',
|
||||
role: 'staff',
|
||||
},
|
||||
{
|
||||
name: 'Specific Student...',
|
||||
role: 'student',
|
||||
user_name: '',
|
||||
},
|
||||
{
|
||||
group_id: 1,
|
||||
name: 'Audit',
|
||||
role: 'student',
|
||||
user_partition_id: 50,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore();
|
||||
courseware = store.getState().courseware;
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
|
||||
mockData = {
|
||||
courseId: courseware.courseId,
|
||||
onError: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active: {
|
||||
course_key: courseware.courseId,
|
||||
group_id: null,
|
||||
role: 'staff',
|
||||
user_name: null,
|
||||
user_partition_id: null,
|
||||
group_name: null,
|
||||
},
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
});
|
||||
|
||||
it('renders masquerade name correctly', async () => {
|
||||
render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Staff');
|
||||
});
|
||||
|
||||
masqueradeOptions.forEach((option) => {
|
||||
it(`marks role ${option.role} as active`, async () => {
|
||||
const active = {
|
||||
course_key: courseware.courseId,
|
||||
group_id: option.group_id ?? null,
|
||||
role: option.role,
|
||||
user_name: option.user_name ?? null,
|
||||
user_partition_id: option.user_partition_id ?? null,
|
||||
group_name: null,
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active,
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle')!;
|
||||
fireEvent.click(dropdownToggle);
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement;
|
||||
await within(dropdownMenu).findAllByRole('button'); // Wait for the buttons to load/render
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === option.name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the clicks with toggle', async () => {
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle')!;
|
||||
fireEvent.click(dropdownToggle);
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement;
|
||||
const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' });
|
||||
fireEvent.click(studentOption);
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === 'Specific Student...') {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('can masquerade as a specific user', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Configure our mock:
|
||||
axiosMock.onPost(masqueradeUrl).reply(200, {
|
||||
...mockResponse,
|
||||
active: { ...mockResponse.active, role: null, user_name: 'testUser' },
|
||||
});
|
||||
// Render the masquerade controls:
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
// Select "specific student..."
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle')!;
|
||||
await user.click(dropdownToggle);
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement;
|
||||
const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' });
|
||||
await user.click(studentOption);
|
||||
|
||||
// Enter a username, POST the request to the server
|
||||
const usernameInput = await screen.findByLabelText(/Username or email/);
|
||||
await user.type(usernameInput, 'testuser');
|
||||
expect(axiosMock.history.post).toHaveLength(0);
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('can display an error when failing to masquerade as a specific user', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Configure our mock:
|
||||
axiosMock.onPost(masqueradeUrl).reply(200, { // Note: The API endpoint returns a 200 response on error!
|
||||
success: false,
|
||||
error: 'That user does not exist',
|
||||
});
|
||||
// Render the masquerade controls:
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
// Select "specific student..."
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle')!;
|
||||
await user.click(dropdownToggle);
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement;
|
||||
const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' });
|
||||
await user.click(studentOption);
|
||||
|
||||
// Enter a username, POST the request to the server
|
||||
const usernameInput = await screen.findByLabelText(/Username or email/);
|
||||
await user.type(usernameInput, 'testuser');
|
||||
expect(axiosMock.history.post).toHaveLength(0);
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
await waitFor(() => {
|
||||
expect(mockData.onError).toHaveBeenLastCalledWith('That user does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('can display an error when failing to masquerade as a specific user due to network issues etc', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Configure our mock:
|
||||
axiosMock.onPost(masqueradeUrl).networkError();
|
||||
// Render the masquerade controls:
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
// Select "specific student..."
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle')!;
|
||||
await user.click(dropdownToggle);
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement;
|
||||
const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' });
|
||||
await user.click(studentOption);
|
||||
|
||||
// Enter a username, POST the request to the server
|
||||
const usernameInput = await screen.findByLabelText(/Username or email/);
|
||||
await user.type(usernameInput, 'testuser');
|
||||
expect(axiosMock.history.post).toHaveLength(0);
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
await waitFor(() => {
|
||||
expect(mockData.onError).toHaveBeenLastCalledWith('An error has occurred; please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
130
src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx
Normal file
130
src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
|
||||
import { MasqueradeUserNameInput } from './MasqueradeUserNameInput';
|
||||
import { MasqueradeWidgetOption } from './MasqueradeWidgetOption';
|
||||
import {
|
||||
ActiveMasqueradeData,
|
||||
getMasqueradeOptions,
|
||||
MasqueradeOption,
|
||||
Payload,
|
||||
postMasqueradeOptions,
|
||||
} from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export const MasqueradeWidget: React.FC<Props> = ({ courseId, onError }) => {
|
||||
const intl = useIntl();
|
||||
const [autoFocus, setAutoFocus] = React.useState(false);
|
||||
const [active, setActive] = React.useState<ActiveMasqueradeData>({
|
||||
courseKey: '',
|
||||
role: 'staff',
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
userName: null,
|
||||
userPartitionId: null,
|
||||
});
|
||||
const [available, setAvailable] = React.useState<MasqueradeOption[]>([]);
|
||||
const [shouldShowUserNameInput, setShouldShowUserNameInput] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (active.courseKey === courseId) {
|
||||
return; // Already fetched.
|
||||
}
|
||||
getMasqueradeOptions(courseId).then((data) => {
|
||||
if (data.success) {
|
||||
const newActive = data.active || {};
|
||||
const newAvailable = data.available || [];
|
||||
if (newActive.userName) {
|
||||
setAutoFocus(false);
|
||||
setShouldShowUserNameInput(true);
|
||||
}
|
||||
setActive(newActive);
|
||||
setAvailable(newAvailable);
|
||||
} else {
|
||||
// This was explicitly denied by the backend;
|
||||
// assume it's disabled/unavailable.
|
||||
onError('Unable to get masquerade options');
|
||||
}
|
||||
}).catch((response) => {
|
||||
// There's not much we can do to recover;
|
||||
// if we can't fetch masquerade options,
|
||||
// assume it's disabled/unavailable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unable to get masquerade options', response);
|
||||
});
|
||||
}, [courseId, onError]);
|
||||
|
||||
const handleSubmit = React.useCallback(async (payload: Payload) => {
|
||||
onError(''); // Clear any error
|
||||
return postMasqueradeOptions(courseId, payload);
|
||||
}, [courseId]);
|
||||
|
||||
const toggle = React.useCallback((
|
||||
show: boolean | undefined,
|
||||
groupId: number | null,
|
||||
groupName: string,
|
||||
role: 'staff' | 'student',
|
||||
userName: string,
|
||||
userPartitionId: number | null,
|
||||
) => {
|
||||
setAutoFocus(true);
|
||||
// set masquerade: groupName
|
||||
setShouldShowUserNameInput((prev) => (show === undefined ? !prev : show));
|
||||
setActive(prev => ({
|
||||
...prev,
|
||||
groupId,
|
||||
groupName,
|
||||
role,
|
||||
userName,
|
||||
userPartitionId,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const specificLearnerInputText = intl.formatMessage(messages.placeholder);
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<div className="row">
|
||||
<span className="col-auto col-form-label pl-3"><FormattedMessage {...messages.titleViewAs} /></span>
|
||||
<Dropdown className="flex-shrink-1 mx-1">
|
||||
<Dropdown.Toggle id="masquerade-widget-toggle" variant="inverse-outline-primary">
|
||||
{active.groupName ?? active.userName ?? intl.formatMessage(messages.titleStaff)}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{available.map(group => (
|
||||
<MasqueradeWidgetOption
|
||||
groupId={group.groupId}
|
||||
groupName={group.name}
|
||||
key={group.name}
|
||||
role={group.role}
|
||||
selected={active}
|
||||
userName={group.userName}
|
||||
userPartitionId={group.userPartitionId}
|
||||
userNameInputToggle={toggle}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{shouldShowUserNameInput && (
|
||||
<div className="row mt-2">
|
||||
<span className="col-auto col-form-label pl-3" id="masquerade-search-label">{`${specificLearnerInputText}:`}</span>
|
||||
<MasqueradeUserNameInput
|
||||
id="masquerade-search"
|
||||
className="col-4"
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={active.userName ?? ''}
|
||||
onError={onError}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, {
|
||||
Component,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
|
||||
class MasqueradeWidgetOption extends Component {
|
||||
onClick(event) {
|
||||
// TODO: Remove this hack when we upgrade Paragon
|
||||
// Note: The current version of Paragon does _not_ close dropdown components
|
||||
// automatically (or easily programmatically) when you click on an item.
|
||||
// We can simulate this behavior by programmatically clicking the
|
||||
// toggle button on behalf of the user.
|
||||
// The newest version of Paragon already contains this behavior,
|
||||
// so we can remove this when we upgrade to that point.
|
||||
event.target.parentNode.parentNode.click();
|
||||
const {
|
||||
groupId,
|
||||
groupName,
|
||||
role,
|
||||
userName,
|
||||
userPartitionId,
|
||||
userNameInputToggle,
|
||||
} = this.props;
|
||||
const payload = {};
|
||||
if (userName || userName === '') {
|
||||
userNameInputToggle(true, groupId, groupName, role, userName, userPartitionId);
|
||||
return false;
|
||||
}
|
||||
if (role) {
|
||||
payload.role = role;
|
||||
}
|
||||
if (groupId) {
|
||||
payload.group_id = parseInt(groupId, 10);
|
||||
payload.user_partition_id = parseInt(userPartitionId, 10);
|
||||
}
|
||||
this.props.onSubmit(payload).then(() => {
|
||||
global.location.reload();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelected() {
|
||||
/* eslint-disable arrow-body-style */
|
||||
const isEqual = [
|
||||
'groupId',
|
||||
'role',
|
||||
'userName',
|
||||
'userPartitionId',
|
||||
].reduce((accumulator, currentValue) => {
|
||||
return accumulator && (
|
||||
this.props[currentValue] === this.props.selected[currentValue]
|
||||
);
|
||||
}, true);
|
||||
return isEqual;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
groupName,
|
||||
} = this.props;
|
||||
if (!groupName) {
|
||||
return null;
|
||||
}
|
||||
const selected = this.isSelected();
|
||||
let className;
|
||||
if (selected) {
|
||||
className = 'active';
|
||||
}
|
||||
return (
|
||||
<Dropdown.Item
|
||||
className={className}
|
||||
href="#"
|
||||
onClick={(event) => this.onClick(event)}
|
||||
>
|
||||
{groupName}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
MasqueradeWidgetOption.propTypes = {
|
||||
groupId: PropTypes.number,
|
||||
groupName: PropTypes.string.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
role: PropTypes.string,
|
||||
selected: PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
groupId: PropTypes.number,
|
||||
role: PropTypes.string,
|
||||
userName: PropTypes.string,
|
||||
userPartitionId: PropTypes.number,
|
||||
}),
|
||||
userName: PropTypes.string,
|
||||
userNameInputToggle: PropTypes.func.isRequired,
|
||||
userPartitionId: PropTypes.number,
|
||||
};
|
||||
MasqueradeWidgetOption.defaultProps = {
|
||||
groupId: null,
|
||||
role: null,
|
||||
selected: null,
|
||||
userName: null,
|
||||
userPartitionId: null,
|
||||
};
|
||||
|
||||
export default MasqueradeWidgetOption;
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { getAllByRole } from '@testing-library/dom';
|
||||
import { act } from '@testing-library/react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import MasqueradeWidgetOption from './MasqueradeWidgetOption';
|
||||
import { MasqueradeWidgetOption } from './MasqueradeWidgetOption';
|
||||
import {
|
||||
render, fireEvent, initializeTestStore,
|
||||
} from '../../setupTest';
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
import { ActiveMasqueradeData } from './data/api';
|
||||
|
||||
interface Payload {
|
||||
role?: string;
|
||||
user_name?: string;
|
||||
group_id?: number;
|
||||
user_partition_id?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groupId?: number;
|
||||
groupName: string;
|
||||
onSubmit: (payload: Payload) => Promise<Record<string, any>>;
|
||||
role?: string;
|
||||
selected?: ActiveMasqueradeData;
|
||||
userName?: string;
|
||||
userNameInputToggle?: (
|
||||
show: boolean,
|
||||
groupId: number | null,
|
||||
groupName: string,
|
||||
role: string | null,
|
||||
userName: string,
|
||||
userPartitionId: number | null,
|
||||
) => void;
|
||||
userPartitionId?: number;
|
||||
}
|
||||
|
||||
export const MasqueradeWidgetOption: React.FC<Props> = ({
|
||||
groupId = null,
|
||||
groupName,
|
||||
role = null,
|
||||
selected = null,
|
||||
userName = null,
|
||||
userPartitionId = null,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (userName || userName === '') {
|
||||
props.userNameInputToggle?.(true, groupId, groupName, role, userName, userPartitionId);
|
||||
return false;
|
||||
}
|
||||
const payload: Payload = {};
|
||||
if (role) {
|
||||
payload.role = role;
|
||||
}
|
||||
if (groupId) {
|
||||
payload.group_id = groupId;
|
||||
payload.user_partition_id = userPartitionId!;
|
||||
}
|
||||
props.onSubmit(payload).then(() => {
|
||||
global.location.reload();
|
||||
});
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const isSelected = (
|
||||
groupId === selected?.groupId
|
||||
&& role === selected?.role
|
||||
&& userName === selected?.userName
|
||||
&& userPartitionId === selected?.userPartitionId
|
||||
);
|
||||
|
||||
if (!groupName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = isSelected ? 'active' : '';
|
||||
return (
|
||||
<Dropdown.Item
|
||||
className={className}
|
||||
href="#"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{groupName}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export async function getMasqueradeOptions(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postMasqueradeOptions(courseId, payload) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
47
src/instructor-toolbar/masquerade-widget/data/api.ts
Normal file
47
src/instructor-toolbar/masquerade-widget/data/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export type Role = 'staff' | 'student';
|
||||
|
||||
export interface ActiveMasqueradeData {
|
||||
courseKey: string;
|
||||
role: Role;
|
||||
userName: string | null;
|
||||
userPartitionId: number | null;
|
||||
groupId: number | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
export interface MasqueradeOption {
|
||||
name: string;
|
||||
role: Role;
|
||||
userName?: string;
|
||||
groupId?: number;
|
||||
userPartitionId?: number;
|
||||
}
|
||||
|
||||
export interface MasqueradeStatus {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
active: ActiveMasqueradeData;
|
||||
available: MasqueradeOption[];
|
||||
}
|
||||
|
||||
export interface Payload {
|
||||
role?: Role;
|
||||
user_name?: string;
|
||||
group_id?: number;
|
||||
user_partition_id?: number;
|
||||
}
|
||||
|
||||
export async function getMasqueradeOptions(courseId: string): Promise<MasqueradeStatus> {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postMasqueradeOptions(courseId: string, payload: Payload): Promise<MasqueradeStatus> {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import MasqueradeWidget from './MasqueradeWidget';
|
||||
|
||||
export default MasqueradeWidget;
|
||||
3
src/instructor-toolbar/masquerade-widget/index.ts
Normal file
3
src/instructor-toolbar/masquerade-widget/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { MasqueradeWidget } from './MasqueradeWidget';
|
||||
|
||||
export default MasqueradeWidget;
|
||||
Reference in New Issue
Block a user