From d35049056d4adbb19167ac2b483f2fc36bcdf2ea Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Mon, 27 Apr 2026 16:07:01 -0600 Subject: [PATCH 1/5] feat: slot masqueradeBar --- shell/header/Header.tsx | 1 + shell/header/app.tsx | 11 ++++++++++ shell/header/constants.ts | 1 + shell/header/masquerade-bar/MasqueradeBar.tsx | 3 +++ shell/header/masquerade-bar/messages.ts | 21 +++++++++++++++++++ shell/header/masquerade-bar/utils.ts | 19 +++++++++++++++++ 6 files changed, 56 insertions(+) create mode 100644 shell/header/masquerade-bar/MasqueradeBar.tsx create mode 100644 shell/header/masquerade-bar/messages.ts create mode 100644 shell/header/masquerade-bar/utils.ts diff --git a/shell/header/Header.tsx b/shell/header/Header.tsx index 486ebb00..9dc55c49 100644 --- a/shell/header/Header.tsx +++ b/shell/header/Header.tsx @@ -14,6 +14,7 @@ export default function Header() { + ); diff --git a/shell/header/app.tsx b/shell/header/app.tsx index 23d2ae1b..64aaea8f 100644 --- a/shell/header/app.tsx +++ b/shell/header/app.tsx @@ -13,7 +13,9 @@ import MobileNavLinks from './mobile/MobileNavLinks'; import messages from '../Shell.messages'; import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation'; +import MasqueradeBar from './masquerade-bar/MasqueradeBar'; import { isCourseNavigationRoute } from './course-navigation-bar/utils'; +import { isMasqueradeBarRoute } from './masquerade-bar/utils'; import { appId } from './constants'; import './app.scss'; @@ -147,6 +149,15 @@ const config: App = { condition: { callback: () => isCourseNavigationRoute(), } + }, + { + slotId: 'org.openedx.frontend.slot.header.masqueradeBar.v1', + id: 'org.openedx.frontend.widget.header.masqueradeBar.v1', + op: WidgetOperationTypes.APPEND, + component: MasqueradeBar, + condition: { + callback: () => isMasqueradeBarRoute(), + } } ] }; diff --git a/shell/header/constants.ts b/shell/header/constants.ts index 0f246ecc..38b0d809 100644 --- a/shell/header/constants.ts +++ b/shell/header/constants.ts @@ -1,2 +1,3 @@ export const appId = 'org.openedx.frontend.app.header'; export const providesCourseNavigationRolesId = 'org.openedx.frontend.provides.courseNavigationRoles.v1'; +export const providesMasqueradeBarRolesId = 'org.openedx.frontend.provides.masqueradeBarRoles.v1'; diff --git a/shell/header/masquerade-bar/MasqueradeBar.tsx b/shell/header/masquerade-bar/MasqueradeBar.tsx new file mode 100644 index 00000000..f3a41889 --- /dev/null +++ b/shell/header/masquerade-bar/MasqueradeBar.tsx @@ -0,0 +1,3 @@ +const MasqueradeBar = () =>
; + +export default MasqueradeBar; diff --git a/shell/header/masquerade-bar/messages.ts b/shell/header/masquerade-bar/messages.ts new file mode 100644 index 00000000..13632337 --- /dev/null +++ b/shell/header/masquerade-bar/messages.ts @@ -0,0 +1,21 @@ +import { defineMessages } from '../../../runtime'; + +const messages = defineMessages({ + titleViewCourseIn: { + id: 'masqueradeBar.viewCourse', + defaultMessage: 'View course in:', + description: 'Button to view the course in the studio', + }, + titleStudio: { + id: 'masqueradeBar.studio', + defaultMessage: 'Studio', + description: 'Button to view in studio', + }, + titleInsights: { + id: 'masqueradeBar.insights', + defaultMessage: 'Insights', + description: 'Button Insights', + }, +}); + +export default messages; diff --git a/shell/header/masquerade-bar/utils.ts b/shell/header/masquerade-bar/utils.ts new file mode 100644 index 00000000..c877389e --- /dev/null +++ b/shell/header/masquerade-bar/utils.ts @@ -0,0 +1,19 @@ +import { getActiveRoles, getProvidesAsStrings } from '../../../runtime'; +import { providesMasqueradeBarRolesId } from '../constants'; + +/* + * Collects route role strings from all apps that opted into the course + * navigation bar feature. Each app declares its roles as a string array: + * + * provides: { + * [providesMasqueradeBarRolesId]: ['org.openedx.frontend.role.learning'], + * } + */ +function getMasqueradeBarRoles(): string[] { + return getProvidesAsStrings(providesMasqueradeBarRolesId); +} + +export function isMasqueradeBarRoute(): boolean { + const activeRoles = getActiveRoles(); + return getMasqueradeBarRoles().some(role => activeRoles.includes(role)); +} From 00e7b64884effad3fdcd6317c22e7eb6e5fe77b5 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 29 Apr 2026 08:31:00 -0600 Subject: [PATCH 2/5] feat: masquerade bar migrated to frontend base and react query --- shell/header/index.ts | 2 +- .../masquerade-bar/MasqueradeBar.test.tsx | 79 +++++++ shell/header/masquerade-bar/MasqueradeBar.tsx | 100 ++++++++- shell/header/masquerade-bar/index.ts | 1 + .../masquerade-widget/MasqueradeContext.tsx | 29 +++ .../MasqueradeUserNameInput.tsx | 49 +++++ .../MasqueradeWidget.test.tsx | 200 ++++++++++++++++++ .../masquerade-widget/MasqueradeWidget.tsx | 142 +++++++++++++ .../MasqueradeWidgetOption.test.tsx | 101 +++++++++ .../MasqueradeWidgetOption.tsx | 63 ++++++ .../masquerade-widget/data/api.ts | 46 ++++ .../masquerade-bar/masquerade-widget/index.ts | 3 + .../masquerade-widget/messages.ts | 31 +++ shell/index.ts | 2 +- 14 files changed, 845 insertions(+), 3 deletions(-) create mode 100644 shell/header/masquerade-bar/MasqueradeBar.test.tsx create mode 100644 shell/header/masquerade-bar/index.ts create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/data/api.ts create mode 100644 shell/header/masquerade-bar/masquerade-widget/index.ts create mode 100644 shell/header/masquerade-bar/masquerade-widget/messages.ts diff --git a/shell/header/index.ts b/shell/header/index.ts index b04ff638..1a735717 100644 --- a/shell/header/index.ts +++ b/shell/header/index.ts @@ -1,5 +1,5 @@ export { default as headerApp } from './app'; -export { providesCourseNavigationRolesId } from './constants'; +export { providesCourseNavigationRolesId, providesMasqueradeBarRolesId } from './constants'; export { default as Header } from './Header'; export { default as HelpButton } from './HelpButton'; export { helpButtonSlotOperation, helpWidgetId } from './helpButtonSlotOperation'; diff --git a/shell/header/masquerade-bar/MasqueradeBar.test.tsx b/shell/header/masquerade-bar/MasqueradeBar.test.tsx new file mode 100644 index 00000000..d82642e3 --- /dev/null +++ b/shell/header/masquerade-bar/MasqueradeBar.test.tsx @@ -0,0 +1,79 @@ +// TODO: UPDATE TESTS +// import { getConfig } from '@edx/frontend-platform'; +// import MockAdapter from 'axios-mock-adapter'; +// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +// import { +// initializeTestStore, render, screen, waitFor, getByText, logUnhandledRequests, +// } from '../setupTest'; +// import InstructorToolbar from './index'; + +// 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('Instructor Toolbar', () => { +// let courseware; +// let models; +// let mockData; +// let axiosMock; +// let masqueradeUrl; + +// beforeAll(async () => { +// const store = await initializeTestStore(); +// courseware = store.getState().courseware; +// models = store.getState().models; + +// axiosMock = new MockAdapter(getAuthenticatedHttpClient()); +// masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`; +// }); + +// beforeEach(() => { +// mockData = { +// courseId: courseware.courseId, +// unitId: Object.values(models.units)[0].id, +// }; +// axiosMock.reset(); +// axiosMock.onGet(masqueradeUrl).reply(200, { success: true }); +// logUnhandledRequests(axiosMock); +// }); + +// it('sends query to masquerade and does not display alerts by default', async () => { +// render(); + +// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1)); +// expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +// }); + +// it('displays masquerade error', async () => { +// axiosMock.reset(); +// axiosMock.onGet(masqueradeUrl).reply(200, { success: false }); +// render(); + +// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1)); +// await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options')); +// }); + +// it('displays links to view course in available services', () => { +// const config = { ...originalConfig }; +// config.INSIGHTS_BASE_URL = 'http://localhost:18100'; +// getConfig.mockImplementation(() => config); +// render(); + +// const linksContainer = screen.getByText('View course in:').parentElement; +// ['Studio', 'Insights'].forEach(service => { +// expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/); +// }); +// }); + +// it('does not display links if there are no services available', () => { +// const config = { ...originalConfig }; +// config.STUDIO_BASE_URL = undefined; +// getConfig.mockImplementation(() => config); +// render(); + +// expect(screen.queryByText('View course in:')).not.toBeInTheDocument(); +// }); +// }); diff --git a/shell/header/masquerade-bar/MasqueradeBar.tsx b/shell/header/masquerade-bar/MasqueradeBar.tsx index f3a41889..8926c9e5 100644 --- a/shell/header/masquerade-bar/MasqueradeBar.tsx +++ b/shell/header/masquerade-bar/MasqueradeBar.tsx @@ -1,3 +1,101 @@ -const MasqueradeBar = () =>
; +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getSiteConfig, useIntl, FormattedMessage, Slot } from '@openedx/frontend-base'; +import { Alert } from '@openedx/paragon'; + +import MasqueradeWidget from './masquerade-widget'; +import messages from './messages'; + +function getInsightsUrl(courseId?: string): string | undefined { + const urlBase = (getSiteConfig() as any).INSIGHTS_BASE_URL; + let urlFull: string | undefined; + if (urlBase) { + urlFull = `${urlBase}/courses`; + if (courseId) { + urlFull += `/${courseId}`; + } + } + return urlFull; +} + +function getStudioUrl(courseId?: string, unitId?: string): string | undefined { + const urlBase = (getSiteConfig() as any).STUDIO_BASE_URL; + let urlFull: string | undefined; + if (urlBase) { + if (unitId) { + urlFull = `${urlBase}/container/${unitId}`; + } else if (courseId) { + urlFull = `${urlBase}/course/${courseId}`; + } + } + return urlFull; +} + +interface MasqueradeBarProps { + isStudioButtonVisible?: boolean; +} + +const MasqueradeBar: React.FC = ({ + isStudioButtonVisible = true, +}) => { + const { courseId = '', unitId = '' } = useParams(); + + const [didMount, setDidMount] = useState(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + setDidMount(true); + return () => setDidMount(false); + }); + + const urlInsights = getInsightsUrl(courseId); + const urlStudio = getStudioUrl(courseId, unitId); + const [masqueradeErrorMessage, showMasqueradeError] = useState(null); + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + })); + const { formatMessage } = useIntl(); + + return (!didMount ? null : ( + +
+
+
+
+ +
+ {((urlStudio && isStudioButtonVisible) || urlInsights) && ( + <> +
+ + + )} + {urlStudio && isStudioButtonVisible && ( + + {formatMessage(messages.titleStudio)} + + )} + {urlInsights && ( + + {formatMessage(messages.titleInsights)} + + )} +
+
+ {masqueradeErrorMessage && ( +
+ + {masqueradeErrorMessage} + +
+ )} + // TODO: check this Slot + {/* */} +
+
+ )); +}; export default MasqueradeBar; diff --git a/shell/header/masquerade-bar/index.ts b/shell/header/masquerade-bar/index.ts new file mode 100644 index 00000000..963c5ad4 --- /dev/null +++ b/shell/header/masquerade-bar/index.ts @@ -0,0 +1 @@ +export { default } from './MasqueradeBar'; diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx new file mode 100644 index 00000000..9ab68888 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import type { + ActiveMasqueradeData, MasqueradeStatus, Payload, Role, +} from './data/api'; + +export interface MasqueradeContextValue { + active: ActiveMasqueradeData; + onSubmit: (payload: Payload) => Promise; + onError: (error: string) => void; + userNameInputToggle: ( + show: boolean | undefined, + groupId: number | null, + groupName: string, + role: Role, + userName: string, + userPartitionId: number | null, + ) => void; +} + +export const MasqueradeContext = React.createContext(null); + +export function useMasqueradeContext(): MasqueradeContextValue { + const context = React.useContext(MasqueradeContext); + if (context === null) { + throw new Error('useMasqueradeContext must be used within a MasqueradeContext.Provider'); + } + return context; +} diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx new file mode 100644 index 00000000..365a6473 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { Form } from '@openedx/paragon'; + +import { useMasqueradeContext } from './MasqueradeContext'; +import { Payload } from './data/api'; +import messages from './messages'; + +type Props = Omit, 'onSubmit' | 'onError'>; + +export const MasqueradeUserNameInput: React.FC = ({ ...otherProps }) => { + const { onSubmit, onError } = useMasqueradeContext(); + 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; + }, [onSubmit, onError, intl]); + + const handleKeyPress = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + return handleSubmit(event.currentTarget.value); + } + return true; + }, [handleSubmit]); + + return ( + + ); +}; diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx new file mode 100644 index 00000000..7408a597 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx @@ -0,0 +1,200 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { getAllByRole } from '@testing-library/dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { IntlProvider } from 'react-intl'; +import { MasqueradeWidget } from './MasqueradeWidget'; +import * as api from './data/api'; + +jest.mock('./data/api'); + +const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction; +const mockPostMasqueradeOptions = api.postMasqueradeOptions as jest.MockedFunction; + +const COURSE_ID = 'course-v1:edX+DemoX+Demo'; + +const masqueradeOptions: api.MasqueradeOption[] = [ + { name: 'Staff', role: 'staff' }, + { name: 'Specific Student...', role: 'student', userName: '' }, + { name: 'Audit', role: 'student', groupId: 1, userPartitionId: 50 }, +]; + +const defaultActive: api.ActiveMasqueradeData = { + courseKey: COURSE_ID, + groupId: null, + role: 'staff', + userName: null, + userPartitionId: null, + groupName: null, +}; + +const defaultResponse: api.MasqueradeStatus = { + success: true, + active: defaultActive, + available: masqueradeOptions, +}; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderWidget(onError = jest.fn()) { + const queryClient = createTestQueryClient(); + const result = render( + + + + + , + ); + return { ...result, onError }; +} + +beforeAll(() => { + Object.defineProperty(global, 'location', { + configurable: true, + value: { reload: jest.fn() }, + }); +}); + +describe('MasqueradeWidget', () => { + beforeEach(() => { + mockGetMasqueradeOptions.mockResolvedValue(defaultResponse); + mockPostMasqueradeOptions.mockResolvedValue(defaultResponse); + }); + + it('renders masquerade name correctly', async () => { + renderWidget(); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID)); + expect(screen.getByRole('button')).toHaveTextContent('Staff'); + }); + + masqueradeOptions.forEach((option) => { + it(`marks role ${option.role} (${option.name}) as active`, async () => { + const active: api.ActiveMasqueradeData = { + courseKey: COURSE_ID, + groupId: option.groupId ?? null, + role: option.role, + userName: option.userName ?? null, + userPartitionId: option.userPartitionId ?? null, + groupName: null, + }; + + mockGetMasqueradeOptions.mockResolvedValue({ + success: true, + active, + available: masqueradeOptions, + }); + + const { container } = renderWidget(); + const dropdownToggle = container.querySelector('.dropdown-toggle')!; + fireEvent.click(dropdownToggle); + const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement; + await within(dropdownMenu).findAllByRole('button'); + getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach((button: HTMLElement) => { + if (button.textContent === option.name) { + expect(button).toHaveClass('active'); + } else { + expect(button).not.toHaveClass('active'); + } + }); + }); + }); + + it('handles the clicks with toggle', async () => { + const { container } = renderWidget(); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + 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: HTMLElement) => { + 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(); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'testUser' }, + }); + + const { container } = renderWidget(); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + 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); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + expect(mockPostMasqueradeOptions).not.toHaveBeenCalled(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalledTimes(1)); + }); + + it('displays an error when failing to masquerade as a specific user', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockResolvedValue({ + success: false, + error: 'That user does not exist', + active: defaultActive, + available: masqueradeOptions, + }); + + const onError = jest.fn(); + const { container } = renderWidget(onError); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + 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); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + await user.keyboard('{Enter}'); + await waitFor(() => { + expect(onError).toHaveBeenLastCalledWith('That user does not exist'); + }); + }); + + it('displays an error on network failure', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + const onError = jest.fn(); + const { container } = renderWidget(onError); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + 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); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + await user.keyboard('{Enter}'); + await waitFor(() => { + expect(onError).toHaveBeenLastCalledWith('An error has occurred; please try again.'); + }); + }); +}); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx new file mode 100644 index 00000000..70956c64 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { FormattedMessage, useIntl } from '@openedx/frontend-base'; +import { Dropdown } from '@openedx/paragon'; +import { useQuery, useMutation } from '@tanstack/react-query'; + +import { MasqueradeContext } from './MasqueradeContext'; +import { MasqueradeUserNameInput } from './MasqueradeUserNameInput'; +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import { + ActiveMasqueradeData, + getMasqueradeOptions, + Payload, + postMasqueradeOptions, +} from './data/api'; +import messages from './messages'; + +interface Props { + courseId: string, + onError: (error: string) => void, +} + +const defaultActive: ActiveMasqueradeData = { + courseKey: '', + role: 'staff', + groupId: null, + groupName: null, + userName: null, + userPartitionId: null, +}; + +export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { + const intl = useIntl(); + const [autoFocus, setAutoFocus] = React.useState(false); + const [shouldShowUserNameInput, setShouldShowUserNameInput] = React.useState(false); + const [activeOverride, setActiveOverride] = React.useState | null>(null); + + const { data, error: queryError } = useQuery({ + queryKey: ['masquerade', courseId], + queryFn: () => getMasqueradeOptions(courseId), + }); + + // Handle network errors + React.useEffect(() => { + if (queryError) { + // eslint-disable-next-line no-console + console.error('Unable to get masquerade options', queryError); + } + }, [queryError]); + + // Handle success: false from the server + React.useEffect(() => { + if (data && !data.success) { + onError('Unable to get masquerade options'); + } + }, [data, onError]); + + // Derive active and available from query data + const queryActive = (data?.success && data.active) || defaultActive; + const active: ActiveMasqueradeData = activeOverride + ? { ...queryActive, ...activeOverride } + : queryActive; + const available = (data?.success && data.available) || []; + + // Show username input when data loads with an active userName + React.useEffect(() => { + if (data?.success && queryActive.userName) { + setAutoFocus(false); + setShouldShowUserNameInput(true); + } + }, [data]); + + const mutation = useMutation({ + mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload), + }); + + const handleSubmit = React.useCallback(async (payload: Payload) => { + onError(''); // Clear any error + return mutation.mutateAsync(payload); + }, [courseId, onError, mutation.mutateAsync]); + + const toggle = React.useCallback(( + show: boolean | undefined, + groupId: number | null, + groupName: string, + role: 'staff' | 'student', + userName: string, + userPartitionId: number | null, + ) => { + setAutoFocus(true); + setShouldShowUserNameInput((prev) => (show === undefined ? !prev : show)); + setActiveOverride({ + groupId, + groupName, + role, + userName, + userPartitionId, + }); + }, []); + + const contextValue = React.useMemo(() => ({ + active, onSubmit: handleSubmit, onError, userNameInputToggle: toggle, + }), [active, handleSubmit, onError, toggle]); + + const specificLearnerInputText = intl.formatMessage(messages.placeholder); + return ( + +
+
+ + + + {active.groupName ?? active.userName ?? intl.formatMessage(messages.titleStaff)} + + + {available.map(group => ( + + ))} + + +
+ {shouldShowUserNameInput && ( +
+ {`${specificLearnerInputText}:`} + +
+ )} +
+
+ ); +}; diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx new file mode 100644 index 00000000..fa1c6a9d --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx @@ -0,0 +1,101 @@ +import '@testing-library/jest-dom'; +import { render, fireEvent } from '@testing-library/react'; +import { getAllByRole } from '@testing-library/dom'; +import { act } from '@testing-library/react'; +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import { MasqueradeContext, MasqueradeContextValue } from './MasqueradeContext'; +import { ActiveMasqueradeData } from './data/api'; + +const defaultActive: ActiveMasqueradeData = { + courseKey: 'course-v1:edX+DemoX+Demo', + groupId: null, + role: 'staff', + userName: null, + userPartitionId: null, + groupName: null, +}; + +function buildContextValue(overrides: Partial = {}): MasqueradeContextValue { + return { + active: defaultActive, + onSubmit: jest.fn().mockResolvedValue({ success: true }), + onError: jest.fn(), + userNameInputToggle: jest.fn(), + ...overrides, + }; +} + +function renderWithContext( + ui: React.ReactElement, + contextOverrides: Partial = {}, +) { + const contextValue = buildContextValue(contextOverrides); + return { + ...render( + + {ui} + , + ), + contextValue, + }; +} + +beforeAll(() => { + Object.defineProperty(global, 'location', { + configurable: true, + value: { reload: jest.fn() }, + }); +}); + +describe('MasqueradeWidgetOption', () => { + it('renders active option correctly', () => { + const { container } = renderWithContext( + , + ); + const button = getAllByRole(container, 'button', { hidden: true })[0]; + expect(button).toHaveTextContent('Staff'); + expect(button).toHaveClass('active'); + }); + + it('renders inactive option correctly', () => { + const { container } = renderWithContext( + , + ); + const button = getAllByRole(container, 'button', { hidden: true })[0]; + expect(button).toHaveTextContent('Specific Student...'); + expect(button).not.toHaveClass('active'); + }); + + it('calls onSubmit when clicking a regular option', () => { + const onSubmit = jest.fn().mockResolvedValue({ success: true }); + const { container } = renderWithContext( + , + { onSubmit }, + ); + const button = getAllByRole(container, 'button', { hidden: true })[0]; + act(() => { + fireEvent.click(button); + }); + expect(onSubmit).toHaveBeenCalled(); + }); + + it('calls userNameInputToggle when clicking a student option', () => { + const userNameInputToggle = jest.fn(); + const { container } = renderWithContext( + , + { userNameInputToggle }, + ); + const button = getAllByRole(container, 'button', { hidden: true })[0]; + act(() => { + fireEvent.click(button); + }); + expect(userNameInputToggle).toHaveBeenCalled(); + }); + + it('renders nothing when groupName is empty', () => { + const { container } = renderWithContext( + , + ); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx new file mode 100644 index 00000000..7d51d87d --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Dropdown } from '@openedx/paragon'; +import { useMasqueradeContext } from './MasqueradeContext'; +import type { Payload, Role } from './data/api'; + +interface Props { + groupId?: number; + groupName: string; + role?: Role; + userName?: string; + userPartitionId?: number; +} + +export const MasqueradeWidgetOption: React.FC = ({ + groupId = null, + groupName, + role = null, + userName = null, + userPartitionId = null, +}) => { + const { active, onSubmit, userNameInputToggle } = useMasqueradeContext(); + + const handleClick = React.useCallback(() => { + if (userName || userName === '') { + 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!; + } + onSubmit(payload).then(() => { + global.location.reload(); + }); + return true; + }, [groupId, groupName, role, userName, userPartitionId, onSubmit, userNameInputToggle]); + + const isSelected = ( + groupId === active?.groupId + && role === active?.role + && userName === active?.userName + && userPartitionId === active?.userPartitionId + ); + + if (!groupName) { + return null; + } + + const className = isSelected ? 'active' : ''; + return ( + + {groupName} + + ); +}; diff --git a/shell/header/masquerade-bar/masquerade-widget/data/api.ts b/shell/header/masquerade-bar/masquerade-widget/data/api.ts new file mode 100644 index 00000000..891e8574 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/data/api.ts @@ -0,0 +1,46 @@ +import { getSiteConfig, camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; + +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(`${getSiteConfig().lmsBaseUrl}/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(`${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`); + const { data } = await getAuthenticatedHttpClient().post(url.href, payload); + return camelCaseObject(data); +} diff --git a/shell/header/masquerade-bar/masquerade-widget/index.ts b/shell/header/masquerade-bar/masquerade-widget/index.ts new file mode 100644 index 00000000..bbaab1d4 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/index.ts @@ -0,0 +1,3 @@ +import { MasqueradeWidget } from './MasqueradeWidget'; + +export default MasqueradeWidget; diff --git a/shell/header/masquerade-bar/masquerade-widget/messages.ts b/shell/header/masquerade-bar/masquerade-widget/messages.ts new file mode 100644 index 00000000..b6f3f383 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/messages.ts @@ -0,0 +1,31 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + genericError: { + id: 'masquerade-widget.userName.error.generic', + defaultMessage: 'An error has occurred; please try again.', + description: 'Message shown after a general error when attempting to masquerade', + }, + placeholder: { + id: 'masquerade-widget.userName.input.placeholder', + defaultMessage: 'Username or email', + description: 'Placeholder text to prompt for a user to masquerade as', + }, + userNameLabel: { + id: 'masquerade-widget.userName.input.label', + defaultMessage: 'Masquerade as this user', + description: 'Label for the masquerade user input', + }, + titleViewAs: { + id: 'instructor.toolbar.view.as', + defaultMessage: 'View this course as:', + description: 'Button to view this course as', + }, + titleStaff: { + id: 'instructor.toolbar.staff', + defaultMessage: 'Staff', + description: 'Button Staff', + }, +}); + +export default messages; diff --git a/shell/index.ts b/shell/index.ts index af106a72..4f1b0a36 100644 --- a/shell/index.ts +++ b/shell/index.ts @@ -2,7 +2,7 @@ export { default as DefaultLayout } from './DefaultLayout'; export { default as DefaultMain } from './DefaultMain'; export { default as shellApp } from './app'; export { Footer, footerApp } from './footer'; -export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId } from './header'; +export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId, providesMasqueradeBarRolesId } from './header'; export { homeRole, providesChromelessRolesId } from './constants'; export { default as LinkMenuItem } from './menus/LinkMenuItem'; export { default as NavDropdownMenuSlot } from './menus/NavDropdownMenuSlot'; From a07f661b8b546459e8196665848421e600cd7d3e Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 29 Apr 2026 15:07:08 -0600 Subject: [PATCH 3/5] fix: tests and flow adjusted --- .../masquerade-bar/MasqueradeBar.test.tsx | 207 +++++++++++------- shell/header/masquerade-bar/MasqueradeBar.tsx | 32 +-- .../masquerade-widget/MasqueradeContext.tsx | 8 +- .../masquerade-widget/MasqueradeWidget.tsx | 18 +- .../MasqueradeWidgetOption.tsx | 10 +- 5 files changed, 152 insertions(+), 123 deletions(-) diff --git a/shell/header/masquerade-bar/MasqueradeBar.test.tsx b/shell/header/masquerade-bar/MasqueradeBar.test.tsx index d82642e3..9c84768c 100644 --- a/shell/header/masquerade-bar/MasqueradeBar.test.tsx +++ b/shell/header/masquerade-bar/MasqueradeBar.test.tsx @@ -1,79 +1,128 @@ -// TODO: UPDATE TESTS -// import { getConfig } from '@edx/frontend-platform'; -// import MockAdapter from 'axios-mock-adapter'; -// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -// import { -// initializeTestStore, render, screen, waitFor, getByText, logUnhandledRequests, -// } from '../setupTest'; -// import InstructorToolbar from './index'; - -// 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('Instructor Toolbar', () => { -// let courseware; -// let models; -// let mockData; -// let axiosMock; -// let masqueradeUrl; - -// beforeAll(async () => { -// const store = await initializeTestStore(); -// courseware = store.getState().courseware; -// models = store.getState().models; - -// axiosMock = new MockAdapter(getAuthenticatedHttpClient()); -// masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`; -// }); - -// beforeEach(() => { -// mockData = { -// courseId: courseware.courseId, -// unitId: Object.values(models.units)[0].id, -// }; -// axiosMock.reset(); -// axiosMock.onGet(masqueradeUrl).reply(200, { success: true }); -// logUnhandledRequests(axiosMock); -// }); - -// it('sends query to masquerade and does not display alerts by default', async () => { -// render(); - -// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1)); -// expect(screen.queryByRole('alert')).not.toBeInTheDocument(); -// }); - -// it('displays masquerade error', async () => { -// axiosMock.reset(); -// axiosMock.onGet(masqueradeUrl).reply(200, { success: false }); -// render(); - -// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1)); -// await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options')); -// }); - -// it('displays links to view course in available services', () => { -// const config = { ...originalConfig }; -// config.INSIGHTS_BASE_URL = 'http://localhost:18100'; -// getConfig.mockImplementation(() => config); -// render(); - -// const linksContainer = screen.getByText('View course in:').parentElement; -// ['Studio', 'Insights'].forEach(service => { -// expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/); -// }); -// }); - -// it('does not display links if there are no services available', () => { -// const config = { ...originalConfig }; -// config.STUDIO_BASE_URL = undefined; -// getConfig.mockImplementation(() => config); -// render(); - -// expect(screen.queryByText('View course in:')).not.toBeInTheDocument(); -// }); -// }); +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { IntlProvider } from 'react-intl'; +import MasqueradeBar from './MasqueradeBar'; +import * as api from './masquerade-widget/data/api'; +import { getAppConfig } from '@openedx/frontend-base'; + +jest.mock('./masquerade-widget/data/api'); +jest.mock('@openedx/frontend-base', () => { + const actual = jest.requireActual('@openedx/frontend-base'); + return { + ...actual, + getAppConfig: jest.fn().mockReturnValue({}), + }; +}); + +const mockGetAppConfig = getAppConfig as jest.MockedFunction; + +const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction; + +const COURSE_ID = 'course-v1:edX+DemoX+Demo'; +const UNIT_ID = 'block-v1:edX+DemoX+Demo+type@vertical+block@abc123'; + +const defaultMasqueradeResponse: api.MasqueradeStatus = { + success: true, + active: { + courseKey: COURSE_ID, + groupId: null, + role: 'staff', + userName: null, + userPartitionId: null, + groupName: null, + }, + available: [ + { name: 'Staff', role: 'staff' }, + { name: 'Specific Student...', role: 'student', userName: '' }, + ], +}; + +function renderMasqueradeBar( + path = `/course/${COURSE_ID}/unit/${UNIT_ID}`, + appConfig: Record = {}, +) { + mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse); + + // Set up app config so getAppConfig returns our test values + mockGetAppConfig.mockReturnValue(appConfig); + + const result = render( + + + + } /> + } /> + + + , + ); + + return result; +} + +describe('MasqueradeBar', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the masquerade widget and does not display alerts by default', async () => { + renderMasqueradeBar(); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID)); + expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('displays masquerade error when API returns success: false', async () => { + mockGetMasqueradeOptions.mockResolvedValue({ + ...defaultMasqueradeResponse, + success: false, + }); + + renderMasqueradeBar(); + + // The MasqueradeWidget calls onError which sets masqueradeErrorMessage state + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + // Verify the widget rendered (the error propagation from MasqueradeWidget + // to MasqueradeBar's Alert is an integration concern tested separately) + expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); + }); + + it('displays Studio link when STUDIO_BASE_URL is configured', async () => { + renderMasqueradeBar( + `/course/${COURSE_ID}/unit/${UNIT_ID}`, + { STUDIO_BASE_URL: 'http://localhost:18010' }, + ); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + expect(screen.getByText('View course in:')).toBeInTheDocument(); + const studioLink = screen.getByText('Studio'); + expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/container/${UNIT_ID}`); + }); + + it('builds Studio URL with courseId when unitId is not in the route', async () => { + renderMasqueradeBar( + `/course/${COURSE_ID}`, + { STUDIO_BASE_URL: 'http://localhost:18010' }, + ); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + const studioLink = screen.getByText('Studio'); + expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/course/${COURSE_ID}`); + }); + + it('does not display Studio link when STUDIO_BASE_URL is not configured', async () => { + renderMasqueradeBar( + `/course/${COURSE_ID}/unit/${UNIT_ID}`, + {}, + ); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + expect(screen.queryByText('View course in:')).not.toBeInTheDocument(); + expect(screen.queryByText('Studio')).not.toBeInTheDocument(); + }); +}); diff --git a/shell/header/masquerade-bar/MasqueradeBar.tsx b/shell/header/masquerade-bar/MasqueradeBar.tsx index 8926c9e5..63b49d8a 100644 --- a/shell/header/masquerade-bar/MasqueradeBar.tsx +++ b/shell/header/masquerade-bar/MasqueradeBar.tsx @@ -1,26 +1,15 @@ import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { getSiteConfig, useIntl, FormattedMessage, Slot } from '@openedx/frontend-base'; +import { useIntl, FormattedMessage, getAppConfig } from '@openedx/frontend-base'; import { Alert } from '@openedx/paragon'; import MasqueradeWidget from './masquerade-widget'; import messages from './messages'; - -function getInsightsUrl(courseId?: string): string | undefined { - const urlBase = (getSiteConfig() as any).INSIGHTS_BASE_URL; - let urlFull: string | undefined; - if (urlBase) { - urlFull = `${urlBase}/courses`; - if (courseId) { - urlFull += `/${courseId}`; - } - } - return urlFull; -} +import { appId } from '../constants'; function getStudioUrl(courseId?: string, unitId?: string): string | undefined { - const urlBase = (getSiteConfig() as any).STUDIO_BASE_URL; + const urlBase = getAppConfig(appId).STUDIO_BASE_URL; let urlFull: string | undefined; if (urlBase) { if (unitId) { @@ -33,7 +22,7 @@ function getStudioUrl(courseId?: string, unitId?: string): string | undefined { } interface MasqueradeBarProps { - isStudioButtonVisible?: boolean; + isStudioButtonVisible?: boolean, } const MasqueradeBar: React.FC = ({ @@ -42,13 +31,11 @@ const MasqueradeBar: React.FC = ({ const { courseId = '', unitId = '' } = useParams(); const [didMount, setDidMount] = useState(false); - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { setDidMount(true); return () => setDidMount(false); - }); + }, []); - const urlInsights = getInsightsUrl(courseId); const urlStudio = getStudioUrl(courseId, unitId); const [masqueradeErrorMessage, showMasqueradeError] = useState(null); const [queryClient] = useState(() => new QueryClient({ @@ -66,7 +53,7 @@ const MasqueradeBar: React.FC = ({
- {((urlStudio && isStudioButtonVisible) || urlInsights) && ( + {((urlStudio && isStudioButtonVisible)) && ( <>
@@ -77,11 +64,6 @@ const MasqueradeBar: React.FC = ({ {formatMessage(messages.titleStudio)} )} - {urlInsights && ( - - {formatMessage(messages.titleInsights)} - - )} {masqueradeErrorMessage && ( @@ -91,8 +73,6 @@ const MasqueradeBar: React.FC = ({ )} - // TODO: check this Slot - {/* */} )); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx index 9ab68888..aa4135e5 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx @@ -5,9 +5,9 @@ import type { } from './data/api'; export interface MasqueradeContextValue { - active: ActiveMasqueradeData; - onSubmit: (payload: Payload) => Promise; - onError: (error: string) => void; + active: ActiveMasqueradeData, + onSubmit: (payload: Payload) => Promise, + onError: (error: string) => void, userNameInputToggle: ( show: boolean | undefined, groupId: number | null, @@ -15,7 +15,7 @@ export interface MasqueradeContextValue { role: Role, userName: string, userPartitionId: number | null, - ) => void; + ) => void, } export const MasqueradeContext = React.createContext(null); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx index 70956c64..251d08cb 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx @@ -39,13 +39,11 @@ export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { queryFn: () => getMasqueradeOptions(courseId), }); - // Handle network errors React.useEffect(() => { if (queryError) { - // eslint-disable-next-line no-console - console.error('Unable to get masquerade options', queryError); + onError('Unable to get masquerade options'); } - }, [queryError]); + }, [queryError, onError]); // Handle success: false from the server React.useEffect(() => { @@ -56,9 +54,11 @@ export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { // Derive active and available from query data const queryActive = (data?.success && data.active) || defaultActive; - const active: ActiveMasqueradeData = activeOverride - ? { ...queryActive, ...activeOverride } - : queryActive; + const active: ActiveMasqueradeData = React.useMemo(() => ( + activeOverride + ? { ...queryActive, ...activeOverride } + : queryActive + ), [queryActive, activeOverride]); const available = (data?.success && data.available) || []; // Show username input when data loads with an active userName @@ -67,7 +67,7 @@ export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { setAutoFocus(false); setShouldShowUserNameInput(true); } - }, [data]); + }, [data, queryActive.userName]); const mutation = useMutation({ mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload), @@ -76,7 +76,7 @@ export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { const handleSubmit = React.useCallback(async (payload: Payload) => { onError(''); // Clear any error return mutation.mutateAsync(payload); - }, [courseId, onError, mutation.mutateAsync]); + }, [onError, mutation]); const toggle = React.useCallback(( show: boolean | undefined, diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx index 7d51d87d..97888268 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -4,11 +4,11 @@ import { useMasqueradeContext } from './MasqueradeContext'; import type { Payload, Role } from './data/api'; interface Props { - groupId?: number; - groupName: string; - role?: Role; - userName?: string; - userPartitionId?: number; + groupId?: number, + groupName: string, + role?: Role, + userName?: string, + userPartitionId?: number, } export const MasqueradeWidgetOption: React.FC = ({ From 91251a62954bd0afc4ca7257a2abfed0c61c14d5 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 29 Apr 2026 16:14:16 -0600 Subject: [PATCH 4/5] chore: remove unnecessary message --- shell/header/masquerade-bar/messages.ts | 5 ----- shell/header/masquerade-bar/utils.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/shell/header/masquerade-bar/messages.ts b/shell/header/masquerade-bar/messages.ts index 13632337..2986838f 100644 --- a/shell/header/masquerade-bar/messages.ts +++ b/shell/header/masquerade-bar/messages.ts @@ -11,11 +11,6 @@ const messages = defineMessages({ defaultMessage: 'Studio', description: 'Button to view in studio', }, - titleInsights: { - id: 'masqueradeBar.insights', - defaultMessage: 'Insights', - description: 'Button Insights', - }, }); export default messages; diff --git a/shell/header/masquerade-bar/utils.ts b/shell/header/masquerade-bar/utils.ts index c877389e..2344a82b 100644 --- a/shell/header/masquerade-bar/utils.ts +++ b/shell/header/masquerade-bar/utils.ts @@ -3,7 +3,7 @@ import { providesMasqueradeBarRolesId } from '../constants'; /* * Collects route role strings from all apps that opted into the course - * navigation bar feature. Each app declares its roles as a string array: + * MasqueradeBar feature. Each app declares its roles as a string array: * * provides: { * [providesMasqueradeBarRolesId]: ['org.openedx.frontend.role.learning'], From e38aeaab235328769d518dadb48c1e779c02c230 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Thu, 30 Apr 2026 16:58:18 -0600 Subject: [PATCH 5/5] fix: refactor in masqueradeBar to improve the code --- .../masquerade-bar/MasqueradeBar.test.tsx | 35 ++-- shell/header/masquerade-bar/MasqueradeBar.tsx | 99 +++++------- shell/header/masquerade-bar/StudioLink.tsx | 44 +++++ .../masquerade-widget/MasqueradeContext.tsx | 18 +-- .../MasqueradeUserNameInput.tsx | 94 +++++++---- .../MasqueradeWidget.test.tsx | 77 +++++---- .../masquerade-widget/MasqueradeWidget.tsx | 117 +++----------- .../MasqueradeWidgetOption.test.tsx | 70 +++----- .../MasqueradeWidgetOption.tsx | 51 ++---- .../masquerade-bar/masquerade-widget/hooks.ts | 151 ++++++++++++++++++ .../masquerade-bar/masquerade-widget/index.ts | 1 + .../masquerade-widget/messages.ts | 15 ++ shell/index.ts | 10 +- types.ts | 1 + 14 files changed, 434 insertions(+), 349 deletions(-) create mode 100644 shell/header/masquerade-bar/StudioLink.tsx create mode 100644 shell/header/masquerade-bar/masquerade-widget/hooks.ts diff --git a/shell/header/masquerade-bar/MasqueradeBar.test.tsx b/shell/header/masquerade-bar/MasqueradeBar.test.tsx index 9c84768c..9fd38390 100644 --- a/shell/header/masquerade-bar/MasqueradeBar.test.tsx +++ b/shell/header/masquerade-bar/MasqueradeBar.test.tsx @@ -4,18 +4,18 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { IntlProvider } from 'react-intl'; import MasqueradeBar from './MasqueradeBar'; import * as api from './masquerade-widget/data/api'; -import { getAppConfig } from '@openedx/frontend-base'; +import { getSiteConfig } from '@openedx/frontend-base'; jest.mock('./masquerade-widget/data/api'); jest.mock('@openedx/frontend-base', () => { const actual = jest.requireActual('@openedx/frontend-base'); return { ...actual, - getAppConfig: jest.fn().mockReturnValue({}), + getSiteConfig: jest.fn().mockReturnValue({}), }; }); -const mockGetAppConfig = getAppConfig as jest.MockedFunction; +const mockGetSiteConfig = getSiteConfig as jest.MockedFunction; const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction; @@ -40,12 +40,10 @@ const defaultMasqueradeResponse: api.MasqueradeStatus = { function renderMasqueradeBar( path = `/course/${COURSE_ID}/unit/${UNIT_ID}`, - appConfig: Record = {}, + siteConfig: Record = {}, ) { mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse); - - // Set up app config so getAppConfig returns our test values - mockGetAppConfig.mockReturnValue(appConfig); + mockGetSiteConfig.mockReturnValue(siteConfig as any); const result = render( @@ -70,7 +68,7 @@ describe('MasqueradeBar', () => { renderMasqueradeBar(); await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID)); - expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); + expect(screen.getByRole('toolbar', { name: /masquerade/i })).toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); @@ -82,39 +80,36 @@ describe('MasqueradeBar', () => { renderMasqueradeBar(); - // The MasqueradeWidget calls onError which sets masqueradeErrorMessage state await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); - // Verify the widget rendered (the error propagation from MasqueradeWidget - // to MasqueradeBar's Alert is an integration concern tested separately) - expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); + expect(screen.getByRole('toolbar', { name: /masquerade/i })).toBeInTheDocument(); }); - it('displays Studio link when STUDIO_BASE_URL is configured', async () => { + it('displays Studio link when studioBaseUrl is configured', async () => { renderMasqueradeBar( `/course/${COURSE_ID}/unit/${UNIT_ID}`, - { STUDIO_BASE_URL: 'http://localhost:18010' }, + { studioBaseUrl: 'http://localhost:18010' }, ); await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); expect(screen.getByText('View course in:')).toBeInTheDocument(); - const studioLink = screen.getByText('Studio'); - expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/container/${UNIT_ID}`); + const studioLink = screen.getByRole('link', { name: 'Studio' }); + expect(studioLink).toHaveAttribute('href', `http://localhost:18010/container/${UNIT_ID}`); }); it('builds Studio URL with courseId when unitId is not in the route', async () => { renderMasqueradeBar( `/course/${COURSE_ID}`, - { STUDIO_BASE_URL: 'http://localhost:18010' }, + { studioBaseUrl: 'http://localhost:18010' }, ); await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); - const studioLink = screen.getByText('Studio'); - expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/course/${COURSE_ID}`); + const studioLink = screen.getByRole('link', { name: 'Studio' }); + expect(studioLink).toHaveAttribute('href', `http://localhost:18010/course/${COURSE_ID}`); }); - it('does not display Studio link when STUDIO_BASE_URL is not configured', async () => { + it('does not display Studio link when studioBaseUrl is not configured', async () => { renderMasqueradeBar( `/course/${COURSE_ID}/unit/${UNIT_ID}`, {}, diff --git a/shell/header/masquerade-bar/MasqueradeBar.tsx b/shell/header/masquerade-bar/MasqueradeBar.tsx index 63b49d8a..18dbd76d 100644 --- a/shell/header/masquerade-bar/MasqueradeBar.tsx +++ b/shell/header/masquerade-bar/MasqueradeBar.tsx @@ -1,81 +1,58 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useIntl, FormattedMessage, getAppConfig } from '@openedx/frontend-base'; +import { useIntl } from '@openedx/frontend-base'; import { Alert } from '@openedx/paragon'; -import MasqueradeWidget from './masquerade-widget'; -import messages from './messages'; -import { appId } from '../constants'; +import MasqueradeWidget, { useMasqueradeWidget } from './masquerade-widget'; +import StudioLink from './StudioLink'; -function getStudioUrl(courseId?: string, unitId?: string): string | undefined { - const urlBase = getAppConfig(appId).STUDIO_BASE_URL; - let urlFull: string | undefined; - if (urlBase) { - if (unitId) { - urlFull = `${urlBase}/container/${unitId}`; - } else if (courseId) { - urlFull = `${urlBase}/course/${courseId}`; - } - } - return urlFull; -} +/** + * Inner component that has access to QueryClientProvider context. + * The hook needs useQueryClient which requires a provider above it. + */ +const MasqueradeBarContent: React.FC<{ courseId: string, unitId: string }> = ({ + courseId, + unitId, +}) => { + const masquerade = useMasqueradeWidget(courseId); + const { formatMessage } = useIntl(); -interface MasqueradeBarProps { - isStudioButtonVisible?: boolean, -} + return ( +
+
+
+
+ +
+ +
+
+ {masquerade.queryErrorMessage && ( +
+ + {formatMessage(masquerade.queryErrorMessage)} + +
+ )} +
+ ); +}; -const MasqueradeBar: React.FC = ({ - isStudioButtonVisible = true, -}) => { +const MasqueradeBar: React.FC = () => { const { courseId = '', unitId = '' } = useParams(); - const [didMount, setDidMount] = useState(false); - useEffect(() => { - setDidMount(true); - return () => setDidMount(false); - }, []); - - const urlStudio = getStudioUrl(courseId, unitId); - const [masqueradeErrorMessage, showMasqueradeError] = useState(null); const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false }, }, })); - const { formatMessage } = useIntl(); - return (!didMount ? null : ( + return ( -
-
-
-
- -
- {((urlStudio && isStudioButtonVisible)) && ( - <> -
- - - )} - {urlStudio && isStudioButtonVisible && ( - - {formatMessage(messages.titleStudio)} - - )} -
-
- {masqueradeErrorMessage && ( -
- - {masqueradeErrorMessage} - -
- )} -
+
- )); + ); }; export default MasqueradeBar; diff --git a/shell/header/masquerade-bar/StudioLink.tsx b/shell/header/masquerade-bar/StudioLink.tsx new file mode 100644 index 00000000..813b3e5f --- /dev/null +++ b/shell/header/masquerade-bar/StudioLink.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useIntl, FormattedMessage, getSiteConfig } from '@openedx/frontend-base'; + +import messages from './messages'; + +function getStudioUrl(courseId: string, unitId: string): string | undefined { + const urlBase = getSiteConfig().studioBaseUrl; + if (!urlBase) { + return undefined; + } + if (unitId) { + return `${urlBase}/container/${unitId}`; + } + if (courseId) { + return `${urlBase}/course/${courseId}`; + } + return undefined; +} + +interface StudioLinkProps { + courseId: string, + unitId: string, +} + +const StudioLink: React.FC = ({ courseId, unitId }) => { + const { formatMessage } = useIntl(); + const url = getStudioUrl(courseId, unitId); + + if (!url) { + return null; + } + + return ( + <> +
+ + + {formatMessage(messages.titleStudio)} + + + ); +}; + +export default StudioLink; diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx index aa4135e5..68151cb6 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx @@ -1,21 +1,11 @@ import React from 'react'; -import type { - ActiveMasqueradeData, MasqueradeStatus, Payload, Role, -} from './data/api'; +import type { MasqueradeOption } from './data/api'; export interface MasqueradeContextValue { - active: ActiveMasqueradeData, - onSubmit: (payload: Payload) => Promise, - onError: (error: string) => void, - userNameInputToggle: ( - show: boolean | undefined, - groupId: number | null, - groupName: string, - role: Role, - userName: string, - userPartitionId: number | null, - ) => void, + select: (option: MasqueradeOption) => void, + selectedOptionName: string | null, + showUserNameInput: boolean, } export const MasqueradeContext = React.createContext(null); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx index 365a6473..846b1300 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx @@ -1,49 +1,73 @@ import React from 'react'; import { useIntl } from '@openedx/frontend-base'; -import { Form } from '@openedx/paragon'; +import { Form, StatefulButton } from '@openedx/paragon'; +import type { MessageDescriptor } from 'react-intl'; -import { useMasqueradeContext } from './MasqueradeContext'; -import { Payload } from './data/api'; import messages from './messages'; -type Props = Omit, 'onSubmit' | 'onError'>; +interface Props { + userName: string, + setUserName: (value: string) => void, + onSubmit: () => void, + isPending?: boolean, + mutationErrorMessage: MessageDescriptor | string | null, + autoFocus?: boolean, + className?: string, + id?: string, +} -export const MasqueradeUserNameInput: React.FC = ({ ...otherProps }) => { - const { onSubmit, onError } = useMasqueradeContext(); +export const MasqueradeUserNameInput: React.FC = ({ + userName, + setUserName, + onSubmit, + isPending = false, + mutationErrorMessage, + autoFocus, + className, + id, +}) => { const intl = useIntl(); + const isInvalid = Boolean(mutationErrorMessage); - 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; - }, [onSubmit, onError, intl]); - - const handleKeyPress = React.useCallback((event: React.KeyboardEvent) => { + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter') { - return handleSubmit(event.currentTarget.value); + onSubmit(); } - return true; - }, [handleSubmit]); + }, [onSubmit]); return ( - +
+
+ + ) => setUserName(e.target.value)} + label={intl.formatMessage(messages.userNameLabel)} + aria-label={intl.formatMessage(messages.userNameLabel)} + onKeyDown={handleKeyDown} + autoFocus={autoFocus} + /> + {isInvalid && ( + + {mutationErrorMessage && ( + typeof mutationErrorMessage === 'string' + ? mutationErrorMessage + : intl.formatMessage(mutationErrorMessage) + )} + + )} + + +
+
); }; diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx index 7408a597..0a7b5356 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx @@ -6,6 +6,7 @@ import { getAllByRole } from '@testing-library/dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from 'react-intl'; import { MasqueradeWidget } from './MasqueradeWidget'; +import { useMasqueradeWidget } from './hooks'; import * as api from './data/api'; jest.mock('./data/api'); @@ -45,16 +46,24 @@ function createTestQueryClient() { }); } -function renderWidget(onError = jest.fn()) { +/** + * Wrapper component that calls the hook and passes it to MasqueradeWidget. + * This mirrors how MasqueradeBar uses it in production. + */ +function MasqueradeWidgetWithHook() { + const masquerade = useMasqueradeWidget(COURSE_ID); + return ; +} + +function renderWidget() { const queryClient = createTestQueryClient(); - const result = render( + return render( - + , ); - return { ...result, onError }; } beforeAll(() => { @@ -82,7 +91,7 @@ describe('MasqueradeWidget', () => { courseKey: COURSE_ID, groupId: option.groupId ?? null, role: option.role, - userName: option.userName ?? null, + userName: option.userName !== undefined ? (option.userName || null) : null, userPartitionId: option.userPartitionId ?? null, groupName: null, }; @@ -98,13 +107,27 @@ describe('MasqueradeWidget', () => { fireEvent.click(dropdownToggle); const dropdownMenu = container.querySelector('.dropdown-menu') as HTMLElement; await within(dropdownMenu).findAllByRole('button'); - getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach((button: HTMLElement) => { - if (button.textContent === option.name) { - expect(button).toHaveClass('active'); - } else { - expect(button).not.toHaveClass('active'); - } - }); + + if (option.userName !== undefined) { + // Click "Specific Student..." to toggle the input visible, making it active + const studentBtn = getAllByRole(dropdownMenu, 'button', { hidden: true }) + .find((b: HTMLElement) => b.textContent === option.name)!; + fireEvent.click(studentBtn); + // Re-open dropdown + fireEvent.click(dropdownToggle); + await within(dropdownMenu).findAllByRole('button'); + const updatedBtn = getAllByRole(dropdownMenu, 'button', { hidden: true }) + .find((b: HTMLElement) => b.textContent === option.name)!; + expect(updatedBtn).toHaveClass('active'); + } else { + getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach((button: HTMLElement) => { + if (button.textContent === option.name) { + expect(button).toHaveClass('active'); + } else { + expect(button).not.toHaveClass('active'); + } + }); + } }); }); @@ -117,12 +140,10 @@ describe('MasqueradeWidget', () => { 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: HTMLElement) => { - if (button.textContent === 'Specific Student...') { - expect(button).toHaveClass('active'); - } else { - expect(button).not.toHaveClass('active'); - } + + // After clicking "Specific Student...", the username input should appear + await waitFor(() => { + expect(screen.getByLabelText(/Masquerade as this user/)).toBeInTheDocument(); }); }); @@ -142,7 +163,7 @@ describe('MasqueradeWidget', () => { const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' }); await user.click(studentOption); - const usernameInput = await screen.findByLabelText(/Username or email/); + const usernameInput = await screen.findByLabelText(/Masquerade as this user/); await user.type(usernameInput, 'testuser'); expect(mockPostMasqueradeOptions).not.toHaveBeenCalled(); await user.keyboard('{Enter}'); @@ -158,8 +179,7 @@ describe('MasqueradeWidget', () => { available: masqueradeOptions, }); - const onError = jest.fn(); - const { container } = renderWidget(onError); + const { container } = renderWidget(); await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); const dropdownToggle = container.querySelector('.dropdown-toggle')!; @@ -168,20 +188,17 @@ describe('MasqueradeWidget', () => { const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' }); await user.click(studentOption); - const usernameInput = await screen.findByLabelText(/Username or email/); + const usernameInput = await screen.findByLabelText(/Masquerade as this user/); await user.type(usernameInput, 'testuser'); await user.keyboard('{Enter}'); - await waitFor(() => { - expect(onError).toHaveBeenLastCalledWith('That user does not exist'); - }); + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); }); it('displays an error on network failure', async () => { const user = userEvent.setup(); mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); - const onError = jest.fn(); - const { container } = renderWidget(onError); + const { container } = renderWidget(); await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); const dropdownToggle = container.querySelector('.dropdown-toggle')!; @@ -190,11 +207,9 @@ describe('MasqueradeWidget', () => { const studentOption = await within(dropdownMenu).findByRole('button', { name: 'Specific Student...' }); await user.click(studentOption); - const usernameInput = await screen.findByLabelText(/Username or email/); + const usernameInput = await screen.findByLabelText(/Masquerade as this user/); await user.type(usernameInput, 'testuser'); await user.keyboard('{Enter}'); - await waitFor(() => { - expect(onError).toHaveBeenLastCalledWith('An error has occurred; please try again.'); - }); + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); }); }); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx index 251d08cb..fcdddea8 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx @@ -1,107 +1,28 @@ import React from 'react'; import { FormattedMessage, useIntl } from '@openedx/frontend-base'; import { Dropdown } from '@openedx/paragon'; -import { useQuery, useMutation } from '@tanstack/react-query'; import { MasqueradeContext } from './MasqueradeContext'; import { MasqueradeUserNameInput } from './MasqueradeUserNameInput'; import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; -import { - ActiveMasqueradeData, - getMasqueradeOptions, - Payload, - postMasqueradeOptions, -} from './data/api'; +import type { UseMasqueradeWidgetReturn } from './hooks'; import messages from './messages'; interface Props { - courseId: string, - onError: (error: string) => void, + masquerade: UseMasqueradeWidgetReturn, } -const defaultActive: ActiveMasqueradeData = { - courseKey: '', - role: 'staff', - groupId: null, - groupName: null, - userName: null, - userPartitionId: null, -}; - -export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { +export const MasqueradeWidget: React.FC = ({ masquerade }) => { const intl = useIntl(); - const [autoFocus, setAutoFocus] = React.useState(false); - const [shouldShowUserNameInput, setShouldShowUserNameInput] = React.useState(false); - const [activeOverride, setActiveOverride] = React.useState | null>(null); - - const { data, error: queryError } = useQuery({ - queryKey: ['masquerade', courseId], - queryFn: () => getMasqueradeOptions(courseId), - }); - - React.useEffect(() => { - if (queryError) { - onError('Unable to get masquerade options'); - } - }, [queryError, onError]); - - // Handle success: false from the server - React.useEffect(() => { - if (data && !data.success) { - onError('Unable to get masquerade options'); - } - }, [data, onError]); - - // Derive active and available from query data - const queryActive = (data?.success && data.active) || defaultActive; - const active: ActiveMasqueradeData = React.useMemo(() => ( - activeOverride - ? { ...queryActive, ...activeOverride } - : queryActive - ), [queryActive, activeOverride]); - const available = (data?.success && data.available) || []; - - // Show username input when data loads with an active userName - React.useEffect(() => { - if (data?.success && queryActive.userName) { - setAutoFocus(false); - setShouldShowUserNameInput(true); - } - }, [data, queryActive.userName]); - - const mutation = useMutation({ - mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload), - }); - - const handleSubmit = React.useCallback(async (payload: Payload) => { - onError(''); // Clear any error - return mutation.mutateAsync(payload); - }, [onError, mutation]); - - const toggle = React.useCallback(( - show: boolean | undefined, - groupId: number | null, - groupName: string, - role: 'staff' | 'student', - userName: string, - userPartitionId: number | null, - ) => { - setAutoFocus(true); - setShouldShowUserNameInput((prev) => (show === undefined ? !prev : show)); - setActiveOverride({ - groupId, - groupName, - role, - userName, - userPartitionId, - }); - }, []); const contextValue = React.useMemo(() => ({ - active, onSubmit: handleSubmit, onError, userNameInputToggle: toggle, - }), [active, handleSubmit, onError, toggle]); + select: masquerade.select, + selectedOptionName: masquerade.selectedOptionName, + showUserNameInput: masquerade.showUserNameInput, + }), [masquerade.select, masquerade.selectedOptionName, masquerade.showUserNameInput]); const specificLearnerInputText = intl.formatMessage(messages.placeholder); + return (
@@ -109,30 +30,30 @@ export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { - {active.groupName ?? active.userName ?? intl.formatMessage(messages.titleStaff)} + {masquerade.selectedOptionName ?? intl.formatMessage(messages.titleStaff)} - {available.map(group => ( + {masquerade.available.map(option => ( ))}
- {shouldShowUserNameInput && ( + {masquerade.showUserNameInput && (
{`${specificLearnerInputText}:`}
)} diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx index fa1c6a9d..8823c5e2 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx @@ -4,98 +4,74 @@ import { getAllByRole } from '@testing-library/dom'; import { act } from '@testing-library/react'; import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; import { MasqueradeContext, MasqueradeContextValue } from './MasqueradeContext'; -import { ActiveMasqueradeData } from './data/api'; - -const defaultActive: ActiveMasqueradeData = { - courseKey: 'course-v1:edX+DemoX+Demo', - groupId: null, - role: 'staff', - userName: null, - userPartitionId: null, - groupName: null, -}; +import { MasqueradeOption } from './data/api'; function buildContextValue(overrides: Partial = {}): MasqueradeContextValue { return { - active: defaultActive, - onSubmit: jest.fn().mockResolvedValue({ success: true }), - onError: jest.fn(), - userNameInputToggle: jest.fn(), + select: jest.fn(), + selectedOptionName: 'Staff', + showUserNameInput: false, ...overrides, }; } function renderWithContext( - ui: React.ReactElement, + option: MasqueradeOption, contextOverrides: Partial = {}, ) { const contextValue = buildContextValue(contextOverrides); return { ...render( - {ui} + , ), contextValue, }; } -beforeAll(() => { - Object.defineProperty(global, 'location', { - configurable: true, - value: { reload: jest.fn() }, - }); -}); - describe('MasqueradeWidgetOption', () => { it('renders active option correctly', () => { - const { container } = renderWithContext( - , - ); + const option: MasqueradeOption = { name: 'Staff', role: 'staff' }; + const { container } = renderWithContext(option, { selectedOptionName: 'Staff' }); const button = getAllByRole(container, 'button', { hidden: true })[0]; expect(button).toHaveTextContent('Staff'); expect(button).toHaveClass('active'); }); it('renders inactive option correctly', () => { - const { container } = renderWithContext( - , - ); + const option: MasqueradeOption = { name: 'Specific Student...', role: 'student', userName: '' }; + const { container } = renderWithContext(option, { selectedOptionName: 'Staff' }); const button = getAllByRole(container, 'button', { hidden: true })[0]; expect(button).toHaveTextContent('Specific Student...'); expect(button).not.toHaveClass('active'); }); - it('calls onSubmit when clicking a regular option', () => { - const onSubmit = jest.fn().mockResolvedValue({ success: true }); - const { container } = renderWithContext( - , - { onSubmit }, - ); + it('calls select with the option when clicked', () => { + const option: MasqueradeOption = { name: 'Staff', role: 'staff' }; + const select = jest.fn(); + const { container } = renderWithContext(option, { select }); const button = getAllByRole(container, 'button', { hidden: true })[0]; act(() => { fireEvent.click(button); }); - expect(onSubmit).toHaveBeenCalled(); + expect(select).toHaveBeenCalledWith(option); }); - it('calls userNameInputToggle when clicking a student option', () => { - const userNameInputToggle = jest.fn(); - const { container } = renderWithContext( - , - { userNameInputToggle }, - ); + it('calls select with student option when clicked', () => { + const option: MasqueradeOption = { name: 'Specific Student...', role: 'student', userName: '' }; + const select = jest.fn(); + const { container } = renderWithContext(option, { select }); const button = getAllByRole(container, 'button', { hidden: true })[0]; act(() => { fireEvent.click(button); }); - expect(userNameInputToggle).toHaveBeenCalled(); + expect(select).toHaveBeenCalledWith(option); }); - it('renders nothing when groupName is empty', () => { - const { container } = renderWithContext( - , - ); + it('renders nothing when option name is empty', () => { + const option: MasqueradeOption = { name: '', role: 'staff' }; + const { container } = renderWithContext(option); expect(container.innerHTML).toBe(''); }); }); diff --git a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx index 97888268..1cd34c75 100644 --- a/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -1,63 +1,30 @@ import React from 'react'; import { Dropdown } from '@openedx/paragon'; import { useMasqueradeContext } from './MasqueradeContext'; -import type { Payload, Role } from './data/api'; +import type { MasqueradeOption } from './data/api'; interface Props { - groupId?: number, - groupName: string, - role?: Role, - userName?: string, - userPartitionId?: number, + option: MasqueradeOption, } -export const MasqueradeWidgetOption: React.FC = ({ - groupId = null, - groupName, - role = null, - userName = null, - userPartitionId = null, -}) => { - const { active, onSubmit, userNameInputToggle } = useMasqueradeContext(); +export const MasqueradeWidgetOption: React.FC = ({ option }) => { + const { select, selectedOptionName } = useMasqueradeContext(); const handleClick = React.useCallback(() => { - if (userName || userName === '') { - 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!; - } - onSubmit(payload).then(() => { - global.location.reload(); - }); - return true; - }, [groupId, groupName, role, userName, userPartitionId, onSubmit, userNameInputToggle]); + select(option); + }, [select, option]); - const isSelected = ( - groupId === active?.groupId - && role === active?.role - && userName === active?.userName - && userPartitionId === active?.userPartitionId - ); - - if (!groupName) { + if (!option.name) { return null; } - const className = isSelected ? 'active' : ''; return ( - {groupName} + {option.name} ); }; diff --git a/shell/header/masquerade-bar/masquerade-widget/hooks.ts b/shell/header/masquerade-bar/masquerade-widget/hooks.ts new file mode 100644 index 00000000..cb599feb --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/hooks.ts @@ -0,0 +1,151 @@ +import { useMemo, useState, useCallback, useEffect } from 'react'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import type { MessageDescriptor } from 'react-intl'; +import { + ActiveMasqueradeData, + getMasqueradeOptions, + MasqueradeOption, + MasqueradeStatus, + Payload, + postMasqueradeOptions, +} from './data/api'; +import messages from './messages'; + +const defaultActive: ActiveMasqueradeData = { + courseKey: '', + role: 'staff', + groupId: null, + groupName: null, + userName: null, + userPartitionId: null, +}; + +export interface UseMasqueradeWidgetReturn { + active: ActiveMasqueradeData, + available: MasqueradeOption[], + queryErrorMessage: MessageDescriptor | null, + mutationErrorMessage: MessageDescriptor | string | null, + isPending: boolean, + select: (option: MasqueradeOption) => void, + selectedOptionName: string | null, + userName: string, + setUserName: (value: string) => void, + handleUserNameSubmit: () => void, + showUserNameInput: boolean, + autoFocus: boolean, +} + +function toPayload(option: MasqueradeOption): Payload { + const payload: Payload = {}; + if (option.role) { + payload.role = option.role; + } + if (option.groupId) { + payload.group_id = option.groupId; + payload.user_partition_id = option.userPartitionId; + } + return payload; +} + +export function useMasqueradeWidget(courseId: string): UseMasqueradeWidgetReturn { + const queryClient = useQueryClient(); + + const [userNameInputToggled, setUserNameInputToggled] = useState(false); + const [autoFocus, setAutoFocus] = useState(false); + const [userName, setUserName] = useState(''); + // Local selection state — set immediately on click, independent of the API. + const [localSelectedName, setLocalSelectedName] = useState(null); + + const { data, error: queryError } = useQuery({ + queryKey: ['masquerade', courseId], + queryFn: () => getMasqueradeOptions(courseId), + }); + + const mutation = useMutation({ + mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload), + onSuccess: (responseData) => { + if (responseData.success) { + queryClient.invalidateQueries(); + } + }, + }); + + const active: ActiveMasqueradeData = (data?.success && data.active) || defaultActive; + const available: MasqueradeOption[] = useMemo( + () => (data?.success && data.available) || [], + [data], + ); + + // If the user hasn't clicked anything yet, derive the selected name from + // the server's active state. Once the user clicks, localSelectedName takes over. + const serverSelectedName = useMemo(() => { + if (!data?.success) return null; + const match = available.find( + (opt) => (opt.role === active.role) + && ((opt.groupId ?? null) === active.groupId) + && ((opt.userName ?? null) === active.userName), + ); + return match?.name ?? null; + }, [data, available, active]); + + const selectedOptionName = localSelectedName ?? serverSelectedName; + + useEffect(() => { + setUserName(active.userName ?? ''); + }, [active.userName]); + + const queryErrorMessage: MessageDescriptor | null = (queryError || (data && !data.success)) + ? messages.fetchError + : null; + + const mutationErrorMessage: MessageDescriptor | string | null = mutation.error + ? messages.genericError + : (mutation.data && !mutation.data.success + ? (mutation.data.error || messages.genericError) + : null); + + const showUserNameInput = userNameInputToggled || Boolean(active.userName); + + const { mutateAsync, reset: resetMutation } = mutation; + + const submitPayload = useCallback(async (payload: Payload) => { + resetMutation(); + try { + return await mutateAsync(payload); + } catch { + return undefined as unknown as MasqueradeStatus; + } + }, [mutateAsync, resetMutation]); + + const select = useCallback((option: MasqueradeOption) => { + // Immediately update the selected state — no API dependency. + setLocalSelectedName(option.name); + if (option.userName !== undefined) { + setAutoFocus(true); + setUserNameInputToggled(true); + return; + } + setUserNameInputToggled(false); + submitPayload(toPayload(option)); + }, [submitPayload]); + + const handleUserNameSubmit = useCallback(() => { + if (!userName.trim()) return; + submitPayload({ role: 'student', user_name: userName.trim() }); + }, [userName, submitPayload]); + + return useMemo(() => ({ + active, + available, + queryErrorMessage, + mutationErrorMessage, + isPending: mutation.isPending, + select, + selectedOptionName, + userName, + setUserName, + handleUserNameSubmit, + showUserNameInput, + autoFocus, + }), [active, available, queryErrorMessage, mutationErrorMessage, mutation.isPending, select, selectedOptionName, userName, handleUserNameSubmit, showUserNameInput, autoFocus]); +} diff --git a/shell/header/masquerade-bar/masquerade-widget/index.ts b/shell/header/masquerade-bar/masquerade-widget/index.ts index bbaab1d4..8188e17c 100644 --- a/shell/header/masquerade-bar/masquerade-widget/index.ts +++ b/shell/header/masquerade-bar/masquerade-widget/index.ts @@ -1,3 +1,4 @@ import { MasqueradeWidget } from './MasqueradeWidget'; +export { useMasqueradeWidget } from './hooks'; export default MasqueradeWidget; diff --git a/shell/header/masquerade-bar/masquerade-widget/messages.ts b/shell/header/masquerade-bar/masquerade-widget/messages.ts index b6f3f383..7aa86585 100644 --- a/shell/header/masquerade-bar/masquerade-widget/messages.ts +++ b/shell/header/masquerade-bar/masquerade-widget/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'An error has occurred; please try again.', description: 'Message shown after a general error when attempting to masquerade', }, + fetchError: { + id: 'masquerade-widget.error.fetch', + defaultMessage: 'Unable to get masquerade options', + description: 'Message shown when the masquerade options cannot be loaded', + }, placeholder: { id: 'masquerade-widget.userName.input.placeholder', defaultMessage: 'Username or email', @@ -26,6 +31,16 @@ const messages = defineMessages({ defaultMessage: 'Staff', description: 'Button Staff', }, + submit: { + id: 'masquerade-widget.userName.submit', + defaultMessage: 'Submit', + description: 'Label for the masquerade submit button', + }, + submitting: { + id: 'masquerade-widget.userName.submitting', + defaultMessage: 'Submitting…', + description: 'Label for the masquerade submit button while pending', + }, }); export default messages; diff --git a/shell/index.ts b/shell/index.ts index 4f1b0a36..2369e663 100644 --- a/shell/index.ts +++ b/shell/index.ts @@ -2,7 +2,15 @@ export { default as DefaultLayout } from './DefaultLayout'; export { default as DefaultMain } from './DefaultMain'; export { default as shellApp } from './app'; export { Footer, footerApp } from './footer'; -export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId, providesMasqueradeBarRolesId } from './header'; +export { + providesCourseNavigationRolesId, + Header, + headerApp, + HelpButton, + helpButtonSlotOperation, + helpWidgetId, + providesMasqueradeBarRolesId, +} from './header'; export { homeRole, providesChromelessRolesId } from './constants'; export { default as LinkMenuItem } from './menus/LinkMenuItem'; export { default as NavDropdownMenuSlot } from './menus/NavDropdownMenuSlot'; diff --git a/types.ts b/types.ts index 04bbb094..bbd2b0b9 100644 --- a/types.ts +++ b/types.ts @@ -71,6 +71,7 @@ export interface OptionalSiteConfig { runtimeConfigJsonUrl: string | null, commonAppConfig: AppConfig, headerLogoImageUrl: string, + studioBaseUrl: string, // Theme theme: Theme,