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:
Braden MacDonald
2024-12-04 09:03:06 -08:00
committed by GitHub
parent f5b6243c61
commit 4a925f9c11
22 changed files with 562 additions and 519 deletions

View File

@@ -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
});
});
});

View File

@@ -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' });

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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));
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}
/>
);
};

View File

@@ -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);

View File

@@ -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');
}
});
});
});

View File

@@ -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.');
});
});
});

View 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>
);
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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);
}

View 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);
}

View File

@@ -1,3 +0,0 @@
import MasqueradeWidget from './MasqueradeWidget';
export default MasqueradeWidget;

View File

@@ -0,0 +1,3 @@
import { MasqueradeWidget } from './MasqueradeWidget';
export default MasqueradeWidget;