diff --git a/package-lock.json b/package-lock.json index a84a98d4f4..4878aac286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", - "@testing-library/user-event": "13.5.0", + "@testing-library/user-event": "14.5.2", "axios-mock-adapter": "2.1.0", "bundlewatch": "^0.4.0", "eslint-import-resolver-webpack": "^0.13.9", @@ -5035,15 +5035,12 @@ } }, "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "dev": true, - "dependencies": { - "@babel/runtime": "^7.12.5" - }, "engines": { - "node": ">=10", + "node": ">=12", "npm": ">=6" }, "peerDependencies": { diff --git a/package.json b/package.json index cad59273d8..3310dbdf7f 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", - "@testing-library/user-event": "13.5.0", + "@testing-library/user-event": "14.5.2", "axios-mock-adapter": "2.1.0", "bundlewatch": "^0.4.0", "eslint-import-resolver-webpack": "^0.13.9", diff --git a/src/course-home/dates-tab/DatesTab.test.jsx b/src/course-home/dates-tab/DatesTab.test.jsx index 329e23f84c..17dcd34ad4 100644 --- a/src/course-home/dates-tab/DatesTab.test.jsx +++ b/src/course-home/dates-tab/DatesTab.test.jsx @@ -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 + }); }); }); diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index 817612d31e..da1ceb5eb5 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -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' }); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx index 1454d4f525..10f8315f01 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx @@ -85,6 +85,7 @@ describe('', () => { }); 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('', () => { 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('', () => { 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(); }); }); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.test.jsx index 328da18ab5..cca273db71 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.test.jsx @@ -43,6 +43,7 @@ describe('', () => { } 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('', () => { }); 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('', () => { }); 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('', () => { }); expect(toggleButton).toBeInTheDocument(); - userEvent.click(toggleButton); + await user.click(toggleButton); expect(mockToggleSidebar).toHaveBeenCalledTimes(1); expect(mockToggleSidebar).toHaveBeenCalledWith(null); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx index 4c48a14b7d..cf3aab7e9b 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx @@ -36,6 +36,7 @@ describe('', () => { }); it('renders correctly when section is incomplete', async () => { + const user = userEvent.setup(); await initTestStore(); const { getByText, container } = render(); @@ -44,12 +45,13 @@ describe('', () => { 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( , @@ -60,7 +62,7 @@ describe('', () => { 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); }); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx index 73d9f3f816..8f2b0dcb16 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx @@ -54,6 +54,7 @@ describe('', () => { }); 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('', () => { 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)); }); }); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx index c46ef0eda2..41776a7e88 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx @@ -88,6 +88,7 @@ describe('', () => { }); 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('', () => { 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); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx deleted file mode 100644 index eb29bb2559..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx +++ /dev/null @@ -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 ( - this.onKeyPress(event)} - type="text" - {...rest} - /> - ); - } -} -MasqueradeUserNameInput.propTypes = { - intl: intlShape.isRequired, - onError: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, -}; -export default injectIntl(MasqueradeUserNameInput); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx new file mode 100644 index 0000000000..8ab7128ae5 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx @@ -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, 'onSubmit' | 'onError'> { + onError: (error: string) => void; + onSubmit: (payload: Payload) => Promise; +} + +export const MasqueradeUserNameInput: React.FC = ({ 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) => { + if (event.key === 'Enter') { + return handleSubmit(event.currentTarget.value); + } + return true; + }, [handleSubmit]); + + return ( + + ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx deleted file mode 100644 index a4edd5d6fb..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx +++ /dev/null @@ -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) => ( - 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 ( -
-
- - - - {masquerade} - - - {this.getOptions()} - - -
- {shouldShowUserNameInput && ( -
- {`${specificLearnerInputText}:`} - this.onError(errorMessage)} - onSubmit={(payload) => this.onSubmit(payload)} - /> -
- )} -
- ); - } -} -MasqueradeWidget.propTypes = { - courseId: PropTypes.string.isRequired, - intl: intlShape.isRequired, - onError: PropTypes.func.isRequired, -}; -MasqueradeWidget.contextType = UserMessagesContext; -export default injectIntl(MasqueradeWidget); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.jsx deleted file mode 100644 index 7349858a37..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.jsx +++ /dev/null @@ -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(); - 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(); - 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(); - 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'); - } - }); - }); -}); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.tsx new file mode 100644 index 0000000000..db6de45a1e --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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.'); + }); + }); +}); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx new file mode 100644 index 0000000000..1edeee13d6 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx @@ -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 = ({ courseId, onError }) => { + const intl = useIntl(); + const [autoFocus, setAutoFocus] = React.useState(false); + const [active, setActive] = React.useState({ + courseKey: '', + role: 'staff', + groupId: null, + groupName: null, + userName: null, + userPartitionId: null, + }); + const [available, setAvailable] = React.useState([]); + 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 ( +
+
+ + + + {active.groupName ?? active.userName ?? intl.formatMessage(messages.titleStaff)} + + + {available.map(group => ( + + ))} + + +
+ {shouldShowUserNameInput && ( +
+ {`${specificLearnerInputText}:`} + +
+ )} +
+ ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx deleted file mode 100644 index 5a7107a6b8..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx +++ /dev/null @@ -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 ( - this.onClick(event)} - > - {groupName} - - ); - } -} -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; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.tsx similarity index 97% rename from src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.jsx rename to src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.tsx index 4e6dde1476..485af935c9 100644 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.jsx +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.test.tsx @@ -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'; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx new file mode 100644 index 0000000000..023146143a --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -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>; + 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 = ({ + 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 ( + + {groupName} + + ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/data/api.js b/src/instructor-toolbar/masquerade-widget/data/api.js deleted file mode 100644 index ccc81a8a72..0000000000 --- a/src/instructor-toolbar/masquerade-widget/data/api.js +++ /dev/null @@ -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); -} diff --git a/src/instructor-toolbar/masquerade-widget/data/api.ts b/src/instructor-toolbar/masquerade-widget/data/api.ts new file mode 100644 index 0000000000..0d9aa2fb1d --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/data/api.ts @@ -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 { + 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 { + const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`); + const { data } = await getAuthenticatedHttpClient().post(url.href, payload); + return camelCaseObject(data); +} diff --git a/src/instructor-toolbar/masquerade-widget/index.js b/src/instructor-toolbar/masquerade-widget/index.js deleted file mode 100644 index f3dcff43bf..0000000000 --- a/src/instructor-toolbar/masquerade-widget/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import MasqueradeWidget from './MasqueradeWidget'; - -export default MasqueradeWidget; diff --git a/src/instructor-toolbar/masquerade-widget/index.ts b/src/instructor-toolbar/masquerade-widget/index.ts new file mode 100644 index 0000000000..bbaab1d4ed --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/index.ts @@ -0,0 +1,3 @@ +import { MasqueradeWidget } from './MasqueradeWidget'; + +export default MasqueradeWidget;