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/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..9fd38390 --- /dev/null +++ b/shell/header/masquerade-bar/MasqueradeBar.test.tsx @@ -0,0 +1,123 @@ +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 { 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, + getSiteConfig: jest.fn().mockReturnValue({}), + }; +}); + +const mockGetSiteConfig = getSiteConfig 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}`, + siteConfig: Record = {}, +) { + mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse); + mockGetSiteConfig.mockReturnValue(siteConfig as any); + + 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.getByRole('toolbar', { name: /masquerade/i })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('displays masquerade error when API returns success: false', async () => { + mockGetMasqueradeOptions.mockResolvedValue({ + ...defaultMasqueradeResponse, + success: false, + }); + + renderMasqueradeBar(); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + expect(screen.getByRole('toolbar', { name: /masquerade/i })).toBeInTheDocument(); + }); + + it('displays Studio link when studioBaseUrl is configured', async () => { + renderMasqueradeBar( + `/course/${COURSE_ID}/unit/${UNIT_ID}`, + { studioBaseUrl: 'http://localhost:18010' }, + ); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + expect(screen.getByText('View course in:')).toBeInTheDocument(); + 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}`, + { studioBaseUrl: 'http://localhost:18010' }, + ); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + + const studioLink = screen.getByRole('link', { name: 'Studio' }); + expect(studioLink).toHaveAttribute('href', `http://localhost:18010/course/${COURSE_ID}`); + }); + + it('does not display Studio link when studioBaseUrl 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 new file mode 100644 index 00000000..18dbd76d --- /dev/null +++ b/shell/header/masquerade-bar/MasqueradeBar.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useIntl } from '@openedx/frontend-base'; +import { Alert } from '@openedx/paragon'; + +import MasqueradeWidget, { useMasqueradeWidget } from './masquerade-widget'; +import StudioLink from './StudioLink'; + +/** + * 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(); + + return ( + + + + + + + + + + {masquerade.queryErrorMessage && ( + + + {formatMessage(masquerade.queryErrorMessage)} + + + )} + + ); +}; + +const MasqueradeBar: React.FC = () => { + const { courseId = '', unitId = '' } = useParams(); + + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + })); + + return ( + + + + ); +}; + +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/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..68151cb6 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import type { MasqueradeOption } from './data/api'; + +export interface MasqueradeContextValue { + select: (option: MasqueradeOption) => void, + selectedOptionName: string | null, + showUserNameInput: boolean, +} + +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..846b1300 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { Form, StatefulButton } from '@openedx/paragon'; +import type { MessageDescriptor } from 'react-intl'; + +import messages from './messages'; + +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 = ({ + userName, + setUserName, + onSubmit, + isPending = false, + mutationErrorMessage, + autoFocus, + className, + id, +}) => { + const intl = useIntl(); + const isInvalid = Boolean(mutationErrorMessage); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onSubmit(); + } + }, [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 new file mode 100644 index 00000000..0a7b5356 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.test.tsx @@ -0,0 +1,215 @@ +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 { useMasqueradeWidget } from './hooks'; +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 }, + }, + }); +} + +/** + * 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(); + return render( + + + + + , + ); +} + +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 !== undefined ? (option.userName || null) : 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'); + + 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'); + } + }); + } + }); + }); + + 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); + + // After clicking "Specific Student...", the username input should appear + await waitFor(() => { + expect(screen.getByLabelText(/Masquerade as this user/)).toBeInTheDocument(); + }); + }); + + 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(/Masquerade as this user/); + 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 { 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(/Masquerade as this user/); + await user.type(usernameInput, 'testuser'); + await user.keyboard('{Enter}'); + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); + }); + + it('displays an error on network failure', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + 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(/Masquerade as this user/); + await user.type(usernameInput, 'testuser'); + await user.keyboard('{Enter}'); + 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 new file mode 100644 index 00000000..fcdddea8 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { FormattedMessage, useIntl } from '@openedx/frontend-base'; +import { Dropdown } from '@openedx/paragon'; + +import { MasqueradeContext } from './MasqueradeContext'; +import { MasqueradeUserNameInput } from './MasqueradeUserNameInput'; +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import type { UseMasqueradeWidgetReturn } from './hooks'; +import messages from './messages'; + +interface Props { + masquerade: UseMasqueradeWidgetReturn, +} + +export const MasqueradeWidget: React.FC = ({ masquerade }) => { + const intl = useIntl(); + + const contextValue = React.useMemo(() => ({ + select: masquerade.select, + selectedOptionName: masquerade.selectedOptionName, + showUserNameInput: masquerade.showUserNameInput, + }), [masquerade.select, masquerade.selectedOptionName, masquerade.showUserNameInput]); + + const specificLearnerInputText = intl.formatMessage(messages.placeholder); + + return ( + + + + + + + {masquerade.selectedOptionName ?? intl.formatMessage(messages.titleStaff)} + + + {masquerade.available.map(option => ( + + ))} + + + + {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 new file mode 100644 index 00000000..8823c5e2 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.test.tsx @@ -0,0 +1,77 @@ +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 { MasqueradeOption } from './data/api'; + +function buildContextValue(overrides: Partial = {}): MasqueradeContextValue { + return { + select: jest.fn(), + selectedOptionName: 'Staff', + showUserNameInput: false, + ...overrides, + }; +} + +function renderWithContext( + option: MasqueradeOption, + contextOverrides: Partial = {}, +) { + const contextValue = buildContextValue(contextOverrides); + return { + ...render( + + + , + ), + contextValue, + }; +} + +describe('MasqueradeWidgetOption', () => { + it('renders active option correctly', () => { + 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 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 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(select).toHaveBeenCalledWith(option); + }); + + 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(select).toHaveBeenCalledWith(option); + }); + + 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 new file mode 100644 index 00000000..1cd34c75 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Dropdown } from '@openedx/paragon'; +import { useMasqueradeContext } from './MasqueradeContext'; +import type { MasqueradeOption } from './data/api'; + +interface Props { + option: MasqueradeOption, +} + +export const MasqueradeWidgetOption: React.FC = ({ option }) => { + const { select, selectedOptionName } = useMasqueradeContext(); + + const handleClick = React.useCallback(() => { + select(option); + }, [select, option]); + + if (!option.name) { + return null; + } + + return ( + + {option.name} + + ); +}; 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/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 new file mode 100644 index 00000000..8188e17c --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/index.ts @@ -0,0 +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 new file mode 100644 index 00000000..7aa86585 --- /dev/null +++ b/shell/header/masquerade-bar/masquerade-widget/messages.ts @@ -0,0 +1,46 @@ +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', + }, + 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', + 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', + }, + 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/header/masquerade-bar/messages.ts b/shell/header/masquerade-bar/messages.ts new file mode 100644 index 00000000..2986838f --- /dev/null +++ b/shell/header/masquerade-bar/messages.ts @@ -0,0 +1,16 @@ +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', + }, +}); + +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..2344a82b --- /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 + * MasqueradeBar 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)); +} diff --git a/shell/index.ts b/shell/index.ts index af106a72..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 } 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,