From 222ce55368668e0a156f71c1f6309303d13ae8a9 Mon Sep 17 00:00:00 2001 From: "Adolfo R. Brandes" Date: Wed, 6 May 2026 15:25:45 -0300 Subject: [PATCH] feat: Course bar with course-tabs navigation and masquerade widget Adds a header strip at the top of the course view that combines two role-aware widgets: the course-tabs navigation (already in tree under its own opt-in) and a new masquerade bar. Both live under a unified shell/header/course-bar/{navigation,masquerade}/ tree and share a course_home metadata fetch. Apps declare course-bar membership under providesCourseBarRolesId (navigation tabs) and additionally enable the masquerade widget on those routes by listing the same role under providesCourseBarMasqueradeRolesId. Masquerade is a refinement of the course bar, not an independent feature: a role only present in the masquerade list is ignored. Course staff can use the masquerade widget to view a course as a different role (Staff, a group like Audit) or as a specific learner. When a selection causes the user's current page to no longer be visible, the bar redirects them: the post-masquerade course-tabs list is the source of truth, and if the current path is no longer in it the user is sent to the first remaining tab (in-app via react-router when possible, hard-redirect otherwise). Co-Authored-By: Diana Villalvazo Co-Authored-By: Jesus Balderrama Co-Authored-By: Claude --- ...p-provides-for-inter-app-configuration.rst | 23 +- runtime/config/index.ts | 1 + shell/header/Header.tsx | 1 + shell/header/app.tsx | 16 +- shell/header/constants.ts | 3 +- .../data/service.ts | 23 + .../masquerade/MasqueradeBar.test.tsx | 99 ++++ .../course-bar/masquerade/MasqueradeBar.tsx | 51 ++ .../masquerade/MasqueradeContext.tsx | 13 + .../course-bar/masquerade/StudioLink.tsx | 48 ++ .../header/course-bar/masquerade/data/api.ts | 45 ++ shell/header/course-bar/masquerade/hooks.ts | 267 +++++++++ shell/header/course-bar/masquerade/index.ts | 1 + .../MasqueradeUserNameInput.tsx | 48 ++ .../MasqueradeWidget.test.tsx | 559 ++++++++++++++++++ .../masquerade-widget/MasqueradeWidget.tsx | 47 ++ .../MasqueradeWidgetOption.test.tsx | 96 +++ .../MasqueradeWidgetOption.tsx | 34 ++ .../masquerade/masquerade-widget/index.ts | 3 + .../masquerade/masquerade-widget/messages.ts | 31 + .../header/course-bar/masquerade/messages.ts | 36 ++ .../navigation}/CourseTabsNavigation.test.tsx | 16 +- .../navigation}/CourseTabsNavigation.tsx | 40 +- .../navigation}/course-tabs-navigation.scss | 0 .../navigation}/messages.ts | 2 +- shell/header/course-bar/utils.test.ts | 128 ++++ shell/header/course-bar/utils.ts | 58 ++ .../course-navigation-bar/utils.test.ts | 68 --- shell/header/course-navigation-bar/utils.ts | 29 - shell/header/index.ts | 2 +- shell/index.ts | 12 +- shell/site.config.dev.tsx | 1 + types.ts | 3 + 33 files changed, 1655 insertions(+), 149 deletions(-) rename shell/header/{course-navigation-bar => course-bar}/data/service.ts (53%) create mode 100644 shell/header/course-bar/masquerade/MasqueradeBar.test.tsx create mode 100644 shell/header/course-bar/masquerade/MasqueradeBar.tsx create mode 100644 shell/header/course-bar/masquerade/MasqueradeContext.tsx create mode 100644 shell/header/course-bar/masquerade/StudioLink.tsx create mode 100644 shell/header/course-bar/masquerade/data/api.ts create mode 100644 shell/header/course-bar/masquerade/hooks.ts create mode 100644 shell/header/course-bar/masquerade/index.ts create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/MasqueradeUserNameInput.tsx create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.test.tsx create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.tsx create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.test.tsx create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.tsx create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/index.ts create mode 100644 shell/header/course-bar/masquerade/masquerade-widget/messages.ts create mode 100644 shell/header/course-bar/masquerade/messages.ts rename shell/header/{course-navigation-bar => course-bar/navigation}/CourseTabsNavigation.test.tsx (93%) rename shell/header/{course-navigation-bar => course-bar/navigation}/CourseTabsNavigation.tsx (64%) rename shell/header/{course-navigation-bar => course-bar/navigation}/course-tabs-navigation.scss (100%) rename shell/header/{course-navigation-bar => course-bar/navigation}/messages.ts (83%) create mode 100644 shell/header/course-bar/utils.test.ts create mode 100644 shell/header/course-bar/utils.ts delete mode 100644 shell/header/course-navigation-bar/utils.test.ts delete mode 100644 shell/header/course-navigation-bar/utils.ts diff --git a/docs/decisions/0013-app-provides-for-inter-app-configuration.rst b/docs/decisions/0013-app-provides-for-inter-app-configuration.rst index f9f5c0bf..11917c34 100644 --- a/docs/decisions/0013-app-provides-for-inter-app-configuration.rst +++ b/docs/decisions/0013-app-provides-for-inter-app-configuration.rst @@ -99,15 +99,18 @@ perspective. Consuming apps bear the responsibility of defining, documenting, and validating the shape of the data they expect. This is acceptable because the data is, by definition, outside frontend-base's domain. -Course navigation bar example ------------------------------ +Course bar example +------------------ As a concrete illustration, the Instructor Dashboard app could declare:: const config: App = { appId: 'org.openedx.frontend.app.instructorDashboard', provides: { - 'org.openedx.frontend.provides.courseNavigationRoles.v1': [ + 'org.openedx.frontend.provides.courseBarRoles.v1': [ + 'org.openedx.frontend.role.instructorDashboard', + ], + 'org.openedx.frontend.provides.courseBarMasqueradeRoles.v1': [ 'org.openedx.frontend.role.instructorDashboard', ], }, @@ -115,12 +118,14 @@ As a concrete illustration, the Instructor Dashboard app could declare:: slots: [...], }; -The header's course navigation bar widget collects ``provides`` entries keyed -to the course navigation roles identifier from all registered apps. It expects -the provided values to be role identifiers, from which it determines both when -to render the navigation bar (by checking ``getActiveRoles()``) and which tab -URLs can be navigated client-side (by resolving roles to route paths via -``getUrlByRouteRole()``). +The header's course bar has two parts: the tab navigation, which renders for +any role declared under ``courseBarRoles``, and the masquerade widget, which +additionally requires the role to be declared under ``courseBarMasqueradeRoles``. +Masquerade is therefore a refinement of the course bar: a role only present +in the masquerade list (without a matching course-bar declaration) is +ignored. The course-bar role list also supplies which tab URLs can be +navigated client-side, by resolving roles to route paths via +``getUrlByRouteRole()``. Rejected alternatives diff --git a/runtime/config/index.ts b/runtime/config/index.ts index a5912fae..25212b6c 100644 --- a/runtime/config/index.ts +++ b/runtime/config/index.ts @@ -123,6 +123,7 @@ let siteConfig: SiteConfig = { // Optional environment: EnvironmentTypes.PRODUCTION, + cmsBaseUrl: '', apps: [], externalRoutes: [], externalLinkUrlOverrides: [], 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..375fbded 100644 --- a/shell/header/app.tsx +++ b/shell/header/app.tsx @@ -12,8 +12,9 @@ import MobileLayout from './mobile/MobileLayout'; import MobileNavLinks from './mobile/MobileNavLinks'; import messages from '../Shell.messages'; -import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation'; -import { isCourseNavigationRoute } from './course-navigation-bar/utils'; +import CourseTabsNavigation from './course-bar/navigation/CourseTabsNavigation'; +import MasqueradeBar from './course-bar/masquerade/MasqueradeBar'; +import { isCourseBarMasqueradeRoute, isCourseBarRoute } from './course-bar/utils'; import { appId } from './constants'; import './app.scss'; @@ -145,7 +146,16 @@ const config: App = { op: WidgetOperationTypes.APPEND, component: CourseTabsNavigation, condition: { - callback: () => isCourseNavigationRoute(), + callback: () => isCourseBarRoute(), + } + }, + { + slotId: 'org.openedx.frontend.slot.header.masqueradeBar.v1', + id: 'org.openedx.frontend.widget.header.masqueradeBar.v1', + op: WidgetOperationTypes.APPEND, + component: MasqueradeBar, + condition: { + callback: () => isCourseBarMasqueradeRoute(), } } ] diff --git a/shell/header/constants.ts b/shell/header/constants.ts index 0f246ecc..b8340c47 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 providesCourseBarRolesId = 'org.openedx.frontend.provides.courseBarRoles.v1'; +export const providesCourseBarMasqueradeRolesId = 'org.openedx.frontend.provides.courseBarMasqueradeRoles.v1'; diff --git a/shell/header/course-navigation-bar/data/service.ts b/shell/header/course-bar/data/service.ts similarity index 53% rename from shell/header/course-navigation-bar/data/service.ts rename to shell/header/course-bar/data/service.ts index 59013723..1ea91d4d 100644 --- a/shell/header/course-navigation-bar/data/service.ts +++ b/shell/header/course-bar/data/service.ts @@ -1,3 +1,4 @@ +import { matchPath } from 'react-router-dom'; import { getSiteConfig, getAuthenticatedHttpClient, camelCaseObject } from '../../../../runtime'; // Raw API response from /api/course_home/course_metadata/ @@ -37,3 +38,25 @@ export async function getCourseHomeCourseMetadata(courseId: string): Promise best.length)) { + best = { tab, length: tabPathname.length }; + } + } + return best?.tab ?? null; +} diff --git a/shell/header/course-bar/masquerade/MasqueradeBar.test.tsx b/shell/header/course-bar/masquerade/MasqueradeBar.test.tsx new file mode 100644 index 00000000..dae3f28c --- /dev/null +++ b/shell/header/course-bar/masquerade/MasqueradeBar.test.tsx @@ -0,0 +1,99 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { IntlProvider } from 'react-intl'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { setSiteConfig, getSiteConfig } from '@openedx/frontend-base'; + +import MasqueradeBar from './MasqueradeBar'; +import * as api from './data/api'; + +jest.mock('./data/api'); + +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: { + groupId: null, + role: 'staff', + userName: null, + userPartitionId: null, + groupName: null, + }, + available: [ + { name: 'Staff', role: 'staff' }, + { name: 'Specific Student...', role: 'student', userName: '' }, + ], +}; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderMasqueradeBar( + path = `/course/${COURSE_ID}/unit/${UNIT_ID}`, + cmsBaseUrl = '', +) { + mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse); + setSiteConfig({ ...getSiteConfig(), cmsBaseUrl }); + + const queryClient = createTestQueryClient(); + return render( + + + + + } /> + } /> + + + + , + ); +} + +describe('MasqueradeBar', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders the masquerade widget', async () => { + renderMasqueradeBar(); + + expect(await screen.findByRole('region', { name: /masquerade bar/i })).toBeInTheDocument(); + expect(screen.getByText('View this course as:')).toBeInTheDocument(); + }); + + it('displays Studio link when cmsBaseUrl is configured', async () => { + renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, 'http://localhost:18010'); + + const studioLink = await screen.findByRole('link', { name: 'Studio' }); + expect(screen.getByText('View course in:')).toBeInTheDocument(); + 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}`, 'http://localhost:18010'); + + const studioLink = await screen.findByRole('link', { name: 'Studio' }); + expect(studioLink).toHaveAttribute('href', `http://localhost:18010/course/${COURSE_ID}`); + }); + + it('does not display Studio link when cmsBaseUrl is not configured', async () => { + renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, ''); + + /* Wait until the bar is visible before asserting the link's absence. */ + await screen.findByRole('region', { name: /masquerade bar/i }); + expect(screen.queryByText('View course in:')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Studio' })).not.toBeInTheDocument(); + }); +}); diff --git a/shell/header/course-bar/masquerade/MasqueradeBar.tsx b/shell/header/course-bar/masquerade/MasqueradeBar.tsx new file mode 100644 index 00000000..dfd65286 --- /dev/null +++ b/shell/header/course-bar/masquerade/MasqueradeBar.tsx @@ -0,0 +1,51 @@ +import { useIntl } from '@openedx/frontend-base'; +import { Alert, Container } from '@openedx/paragon'; +import { useParams } from 'react-router-dom'; + +import { MasqueradeContext } from './MasqueradeContext'; +import MasqueradeWidget from './masquerade-widget'; +import { formatErrorMessage, useMasqueradeState } from './hooks'; +import StudioLink from './StudioLink'; +import messages from './messages'; + +export default function MasqueradeBar() { + const { formatMessage } = useIntl(); + const { courseId = '', unitId = '' } = useParams(); + const masquerade = useMasqueradeState(courseId); + const { + errorMessage, isLoading, isDenied, isUnreachable, + } = masquerade; + + /* Render nothing while we wait for the first response, and when the server + * tells us this user can't masquerade. Other failures fall through to a + * partial bar plus an alert. */ + if (isLoading || isDenied) { + return null; + } + + return ( + +
+
+ +
+ {!isUnreachable && ( +
+ +
+ )} + +
+
+
+ {errorMessage && ( + + + {formatErrorMessage(formatMessage, errorMessage)} + + + )} +
+
+ ); +} diff --git a/shell/header/course-bar/masquerade/MasqueradeContext.tsx b/shell/header/course-bar/masquerade/MasqueradeContext.tsx new file mode 100644 index 00000000..8193ad08 --- /dev/null +++ b/shell/header/course-bar/masquerade/MasqueradeContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +import type { MasqueradeState } from './hooks'; + +export const MasqueradeContext = createContext(null); + +export function useMasqueradeContext(): MasqueradeState { + const context = useContext(MasqueradeContext); + if (context === null) { + throw new Error('useMasqueradeContext must be used within a MasqueradeContext.Provider'); + } + return context; +} diff --git a/shell/header/course-bar/masquerade/StudioLink.tsx b/shell/header/course-bar/masquerade/StudioLink.tsx new file mode 100644 index 00000000..54b403c3 --- /dev/null +++ b/shell/header/course-bar/masquerade/StudioLink.tsx @@ -0,0 +1,48 @@ +import { useIntl, FormattedMessage, getSiteConfig } from '@openedx/frontend-base'; +import { Button } from '@openedx/paragon'; + +import messages from './messages'; + +interface Props { + courseId?: string, + unitId?: string, +} + +function buildStudioUrl(courseId?: string, unitId?: string): string | null { + const base = getSiteConfig().cmsBaseUrl; + if (!base) { + return null; + } + if (unitId) { + return `${base}/container/${unitId}`; + } + if (courseId) { + return `${base}/course/${courseId}`; + } + return null; +} + +export function StudioLink({ courseId, unitId }: Props) { + const { formatMessage } = useIntl(); + const url = buildStudioUrl(courseId, unitId); + + if (!url) { + return null; + } + + return ( + <> +
+ + + + + + + + ); +} + +export default StudioLink; diff --git a/shell/header/course-bar/masquerade/data/api.ts b/shell/header/course-bar/masquerade/data/api.ts new file mode 100644 index 00000000..ca2ee83b --- /dev/null +++ b/shell/header/course-bar/masquerade/data/api.ts @@ -0,0 +1,45 @@ +import { getSiteConfig, camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; + +export type Role = 'staff' | 'student'; + +export interface ActiveMasqueradeData { + 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 MasqueradePayload { + role?: Role, + user_name?: string, + group_id?: number, + user_partition_id?: number, +} + +export async function getMasqueradeOptions(courseId: string): Promise { + const url = `${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`; + const { data } = await getAuthenticatedHttpClient().get(url, {}); + return camelCaseObject(data); +} + +export async function postMasqueradeOptions(courseId: string, payload: MasqueradePayload): Promise { + const url = `${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`; + const { data } = await getAuthenticatedHttpClient().post(url, payload); + return camelCaseObject(data); +} diff --git a/shell/header/course-bar/masquerade/hooks.ts b/shell/header/course-bar/masquerade/hooks.ts new file mode 100644 index 00000000..34aeb151 --- /dev/null +++ b/shell/header/course-bar/masquerade/hooks.ts @@ -0,0 +1,267 @@ +import { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { MessageDescriptor } from 'react-intl'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { + ActiveMasqueradeData, + MasqueradeOption, + MasqueradeStatus, + MasqueradePayload, + getMasqueradeOptions, + postMasqueradeOptions, +} from './data/api'; +import { + CourseHomeCourseMetadata, + courseHomeCourseMetadataQueryKey, + findActiveTab, + getCourseHomeCourseMetadata, +} from '../data/service'; +import { isClientRoute } from '../utils'; +import messages from './messages'; + +const defaultActive: ActiveMasqueradeData = { + role: 'staff', + groupId: null, + groupName: null, + userName: null, + userPartitionId: null, +}; + +function toMasqueradePayload(option: MasqueradeOption): MasqueradePayload { + const payload: MasqueradePayload = { role: option.role }; + if (option.groupId !== undefined && option.userPartitionId !== undefined) { + payload.group_id = option.groupId; + payload.user_partition_id = option.userPartitionId; + } + return payload; +} + +/* + * Whether `option` represents the active masquerade. Three shapes: + * - userName defined: the "Specific Student..." prompt; active when the + * server says we're masquerading as some specific user. + * - groupId defined: a group option; active when the group ids match. + * - neither: a plain role option (e.g. Staff); active when no group/user + * is set. + */ +export function isOptionSelected(option: MasqueradeOption, active: ActiveMasqueradeData): boolean { + if (option.role !== active.role) { + return false; + } + if (option.userName !== undefined) { + return active.userName !== null; + } + if (option.groupId !== undefined) { + return option.groupId === active.groupId + && option.userPartitionId === active.userPartitionId; + } + return active.userName === null && active.groupId === null; +} + +interface HttpishError { + customAttributes?: { httpErrorStatus?: number }, +} + +function getHttpStatus(error: unknown): number | undefined { + return (error as HttpishError | null)?.customAttributes?.httpErrorStatus; +} + +/* + * The server tells us "no, you can't masquerade here" via either HTTP 403 or + * a 200 with `success: false`. Both mean: hide the bar entirely. Other + * failures (network, 5xx) are treated as "couldn't load" and surface as an + * alert. + */ +function isQueryDenied(query: { isError: boolean, error: unknown, data: MasqueradeStatus | undefined }): boolean { + if (query.isError) { + return getHttpStatus(query.error) === 403; + } + return query.data !== undefined && !query.data.success; +} + +/* + * Errors are returned as MessageDescriptors when the frontend can pick the + * message, or as a raw string when the server's `data.error` is more specific + * than anything we'd hand-pick. formatErrorMessage handles both at the + * render site. + */ +export type MasqueradeErrorMessage = MessageDescriptor | string; + +export function formatErrorMessage( + formatMessage: (descriptor: MessageDescriptor) => string, + error: MasqueradeErrorMessage, +): string { + return typeof error === 'string' ? error : formatMessage(error); +} + +function pickErrorMessage( + query: { isError: boolean, error: unknown, data: MasqueradeStatus | undefined }, + mutation: { isError: boolean, error: unknown, data: MasqueradeStatus | undefined }, +): MasqueradeErrorMessage | null { + /* Denial is handled by hiding the bar; only surface truly-failed loads here. */ + if (query.isError && getHttpStatus(query.error) !== 403) { + return messages.failedToLoadOptions; + } + if (mutation.isError) { + return getHttpStatus(mutation.error) === 404 + ? messages.noStudentFound + : messages.genericSubmitError; + } + if (mutation.data && !mutation.data.success) { + return mutation.data.error || messages.genericSubmitError; + } + return null; +} + +export interface MasqueradeState { + active: ActiveMasqueradeData, + available: MasqueradeOption[], + pendingOption: MasqueradeOption | null, + showUserNameInput: boolean, + userName: string, + setUserName: (value: string) => void, + select: (option: MasqueradeOption) => void, + submitUserName: () => void, + errorMessage: MasqueradeErrorMessage | null, + isSubmitting: boolean, + isLoading: boolean, + isDenied: boolean, + isUnreachable: boolean, +} + +export function useMasqueradeState(courseId: string): MasqueradeState { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const location = useLocation(); + /* + * Tracks the option the user just clicked, so the toggle and active-marker + * can update before the server confirms. Cleared once a mutation succeeds + * (the server's active state becomes the truth) or replaced when the user + * picks something else. + */ + const [pendingOption, setPendingOption] = useState(null); + const [userName, setUserName] = useState(''); + + const query = useQuery({ + queryKey: ['masquerade', courseId], + queryFn: () => getMasqueradeOptions(courseId), + }); + + const mutation = useMutation({ + mutationFn: (payload: MasqueradePayload) => postMasqueradeOptions(courseId, payload), + onSuccess: async (data, payload) => { + if (!data?.success) { + /* Server rejected the pick. Clear the optimistic pending state so the + * UI snaps back to the real active option, except for userName + * submissions where the input must stay open for retry. */ + if (payload.user_name === undefined) { + setPendingOption(null); + } + return; + } + + queryClient.invalidateQueries(); + + /* The course-tabs metadata is the source of truth for "what pages this + * user can see in this course." Fetch the post-masquerade tabs and + * redirect to the first one if the current path no longer appears. */ + const meta = await queryClient.fetchQuery({ + queryKey: courseHomeCourseMetadataQueryKey(courseId), + queryFn: () => getCourseHomeCourseMetadata(courseId), + }); + const fallback = meta.tabs[0]; + if (!fallback) { + return; + } + const stillVisible = findActiveTab(meta.tabs, location.pathname); + if (stillVisible) { + return; + } + const targetPath = new URL(fallback.url).pathname; + if (isClientRoute(targetPath)) { + navigate(targetPath, { replace: true }); + } else { + window.location.assign(fallback.url); + } + }, + onError: (_error, payload) => { + /* Same as the !success branch: revert the optimistic pick, but keep the + * userName input open so the user can fix a typo and retry. */ + if (payload.user_name === undefined) { + setPendingOption(null); + } + }, + }); + + const active = (query.data?.success && query.data.active) || defaultActive; + const available = (query.data?.success && query.data.available) || []; + + /* + * Seed the input from active.userName once, on the first successful query. + * After that the input belongs to the user — refetches (e.g. triggered by + * a masquerade change in another tab) must not clobber what they typed. + */ + const hasSeededRef = useRef(false); + useEffect(() => { + if (hasSeededRef.current || !query.isSuccess) { + return; + } + hasSeededRef.current = true; + if (active.userName) { + setUserName(active.userName); + } + }, [query.isSuccess, active.userName]); + + /* + * Clear pendingOption once the refetched active state actually reflects it. + * Clearing in onSuccess instead would briefly flash the old active state + * between mutation success and the refetch landing. + */ + useEffect(() => { + if (pendingOption && isOptionSelected(pendingOption, active)) { + setPendingOption(null); + } + }, [pendingOption, active]); + + const showUserNameInput = active.userName !== null + || pendingOption?.userName !== undefined; + + const errorMessage = pickErrorMessage(query, mutation); + const isDenied = isQueryDenied(query); + /* Query landed but we couldn't load options — bar shows but widget hides. */ + const isUnreachable = query.isError && getHttpStatus(query.error) !== 403; + /* Loading until the first response (success or failure) lands. */ + const isLoading = query.isLoading; + + const select = (option: MasqueradeOption) => { + /* Clear any prior mutation error so it doesn't leak into the new selection. */ + mutation.reset(); + setPendingOption(option); + if (option.userName !== undefined) { + setUserName(option.userName); + return; + } + mutation.mutate(toMasqueradePayload(option)); + }; + + const submitUserName = () => { + mutation.mutate({ role: 'student', user_name: userName }); + }; + + return { + active, + available, + pendingOption, + showUserNameInput, + userName, + setUserName, + select, + submitUserName, + errorMessage, + isSubmitting: mutation.isPending, + isLoading, + isDenied, + isUnreachable, + }; +} diff --git a/shell/header/course-bar/masquerade/index.ts b/shell/header/course-bar/masquerade/index.ts new file mode 100644 index 00000000..963c5ad4 --- /dev/null +++ b/shell/header/course-bar/masquerade/index.ts @@ -0,0 +1 @@ +export { default } from './MasqueradeBar'; diff --git a/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeUserNameInput.tsx b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeUserNameInput.tsx new file mode 100644 index 00000000..2c92685a --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeUserNameInput.tsx @@ -0,0 +1,48 @@ +import { FormEvent } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { + Form, FormControl, FormGroup, StatefulButton, +} from '@openedx/paragon'; + +import { useMasqueradeContext } from '../MasqueradeContext'; +import messages from './messages'; + +interface Props { + id?: string, + className?: string, + autoFocus?: boolean, +} + +export function MasqueradeUserNameInput({ id, className, autoFocus }: Props) { + const { formatMessage } = useIntl(); + const { + userName, setUserName, submitUserName, isSubmitting, + } = useMasqueradeContext(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submitUserName(); + }; + + return ( +
+ + ) => setUserName(event.target.value)} + /> + + + + ); +} diff --git a/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.test.tsx b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.test.tsx new file mode 100644 index 00000000..169518cb --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.test.tsx @@ -0,0 +1,559 @@ +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { IntlProvider } from 'react-intl'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import MasqueradeBar from '../MasqueradeBar'; +import * as api from '../data/api'; +import * as service from '../../data/service'; +import * as sharedUtils from '../../utils'; + +const mockNavigate = jest.fn(); + +jest.mock('../data/api'); +/* Keep findActiveTab and the queryKey real; mock only the network call. */ +jest.mock('../../data/service', () => ({ + ...jest.requireActual('../../data/service'), + getCourseHomeCourseMetadata: jest.fn(), +})); +jest.mock('../../utils', () => ({ + ...jest.requireActual('../../utils'), + isClientRoute: jest.fn(), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction; +const mockPostMasqueradeOptions = api.postMasqueradeOptions as jest.MockedFunction; +const mockGetCourseHomeCourseMetadata = service.getCourseHomeCourseMetadata as jest.MockedFunction; +const mockIsClientRoute = sharedUtils.isClientRoute as jest.MockedFunction; + +const mockLocationAssign = jest.fn(); + +beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, assign: mockLocationAssign }, + }); +}); + +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 = { + 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() { + const queryClient = createTestQueryClient(); + const result = render( + + + + + } /> + + + + , + ); + return { ...result, queryClient }; +} + +describe('MasqueradeWidget', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockGetMasqueradeOptions.mockResolvedValue(defaultResponse); + mockPostMasqueradeOptions.mockResolvedValue(defaultResponse); + mockGetCourseHomeCourseMetadata.mockResolvedValue({ tabs: [] }); + mockIsClientRoute.mockReturnValue(false); + }); + + it('renders the active option name in the dropdown toggle', async () => { + renderWidget(); + expect(await screen.findByRole('button', { name: 'Staff' })).toBeInTheDocument(); + expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID); + }); + + masqueradeOptions.forEach((option) => { + it(`marks role ${option.role} (${option.name}) as active`, async () => { + const user = userEvent.setup(); + mockGetMasqueradeOptions.mockResolvedValue({ + success: true, + active: { + groupId: option.groupId ?? null, + role: option.role, + userName: option.userName ?? null, + userPartitionId: option.userPartitionId ?? null, + groupName: null, + }, + available: masqueradeOptions, + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { expanded: false })); + + const items = await screen.findAllByRole('button', { hidden: true }); + items.filter(button => button.classList.contains('dropdown-item')).forEach((button) => { + if (button.textContent === option.name) { + expect(button).toHaveClass('active'); + } else { + expect(button).not.toHaveClass('active'); + } + }); + }); + }); + + it('highlights "Specific Student..." when masquerading as a specific user', async () => { + const user = userEvent.setup(); + mockGetMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'alice' }, + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { expanded: false })); + + const items = await screen.findAllByRole('button', { hidden: true }); + const dropdownItems = items.filter(b => b.classList.contains('dropdown-item')); + const specific = dropdownItems.find(b => b.textContent === 'Specific Student...')!; + expect(specific).toHaveClass('active'); + dropdownItems.filter(b => b !== specific).forEach((b) => expect(b).not.toHaveClass('active')); + }); + + it('does not overwrite the user\'s typing when the query refetches', async () => { + const user = userEvent.setup(); + mockGetMasqueradeOptions + .mockResolvedValueOnce(defaultResponse) + .mockResolvedValueOnce({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'externalUser' }, + }); + + const { queryClient } = renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + const usernameInput = await screen.findByLabelText(/Username or email/) as HTMLInputElement; + await user.type(usernameInput, 'myDraft'); + expect(usernameInput.value).toBe('myDraft'); + + /* Simulate another tab changing masquerade: invalidate, second mock fires. */ + await queryClient.invalidateQueries({ queryKey: ['masquerade', COURSE_ID] }); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledTimes(2)); + + expect(usernameInput.value).toBe('myDraft'); + }); + + it('submits the group payload when a group option is selected', async () => { + const user = userEvent.setup(); + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalledWith( + COURSE_ID, + { role: 'student', group_id: 1, user_partition_id: 50 }, + )); + }); + + it('clears the masquerade when the Staff option is selected', async () => { + const user = userEvent.setup(); + mockGetMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'alice' }, + }); + + renderWidget(); + /* Toggle shows the active username; it's the only collapsed button. */ + await user.click(await screen.findByRole('button', { expanded: false })); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalledWith( + COURSE_ID, + { role: 'staff' }, + )); + }); + + it('opens the username input when "Specific Student..." is selected', async () => { + const user = userEvent.setup(); + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + expect(await screen.findByLabelText(/Username or email/)).toBeInTheDocument(); + }); + + it('updates the toggle label optimistically when an option is clicked', async () => { + const user = userEvent.setup(); + renderWidget(); + + /* Toggle starts on "Staff" (the active server state). */ + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + /* Toggle should now reflect the click, even though no submit has happened. */ + expect(screen.getByRole('button', { expanded: false })).toHaveTextContent('Specific Student...'); + }); + + it('submits a username and refetches masquerade state', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'testuser' }, + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + expect(mockPostMasqueradeOptions).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalledWith( + COURSE_ID, + { role: 'student', user_name: 'testuser' }, + )); + }); + + it('shows the "no student found" error on a 404', async () => { + const user = userEvent.setup(); + const error = Object.assign(new Error('Not Found'), { + customAttributes: { httpErrorStatus: 404 }, + }); + mockPostMasqueradeOptions.mockRejectedValue(error); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'missing'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect( + await screen.findByText(/No student with this username or email could be found/), + ).toBeInTheDocument(); + }); + + it('shows an alert when masquerade options fail to load', async () => { + mockGetMasqueradeOptions.mockRejectedValue(new Error('Boom')); + + renderWidget(); + + expect( + await screen.findByText(/Unable to load masquerade options/), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Staff' })).not.toBeInTheDocument(); + }); + + it('hides the bar entirely when the server returns success: false', async () => { + mockGetMasqueradeOptions.mockResolvedValue({ ...defaultResponse, success: false }); + + renderWidget(); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + expect(screen.queryByRole('region', { name: /masquerade bar/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/Unable to load masquerade options/)).not.toBeInTheDocument(); + }); + + it('hides the bar entirely on 403', async () => { + const error = Object.assign(new Error('Forbidden'), { + customAttributes: { httpErrorStatus: 403 }, + }); + mockGetMasqueradeOptions.mockRejectedValue(error); + + renderWidget(); + + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); + expect(screen.queryByRole('region', { name: /masquerade bar/i })).not.toBeInTheDocument(); + }); + + it('renders nothing while the initial query is in flight', () => { + mockGetMasqueradeOptions.mockImplementation(() => new Promise(() => { /* never resolves */ })); + + renderWidget(); + + expect(screen.queryByRole('region', { name: /masquerade bar/i })).not.toBeInTheDocument(); + }); + + it('shows a generic error on network failures', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect( + await screen.findByText(/An error has occurred; please try again/), + ).toBeInTheDocument(); + }); + + it('shows an alert below the bar when a dropdown-option mutation fails', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/An error has occurred; please try again/); + /* The alert sits outside the blue bar's container so colour and layout don't fight. */ + expect(alert.closest('.bg-primary')).toBeNull(); + }); + + it('reverts the toggle to the active option when a dropdown-option mutation fails', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + /* Once the failure lands, the toggle must drop the optimistic "Audit" pick + * and snap back to the real active option ("Staff"). */ + await screen.findByRole('alert'); + await waitFor(() => { + expect(document.getElementById('masquerade-widget-toggle')).toHaveTextContent('Staff'); + }); + }); + + it('keeps the input/submit row intact when a username submission fails', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValue(new Error('Network Error')); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'testuser'); + const submit = screen.getByRole('button', { name: 'Submit' }); + await user.click(submit); + + /* Once the error appears, the input and the submit are still in the same flex row. */ + await screen.findByRole('alert'); + expect(usernameInput.closest('form')).toBe(submit.closest('form')); + }); + + it('uses the server-provided error string when success is false', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + success: false, + error: 'Tried to masquerade as a deactivated user.', + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + const usernameInput = await screen.findByLabelText(/Username or email/); + await user.type(usernameInput, 'deactivated'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect( + await screen.findByText('Tried to masquerade as a deactivated user.'), + ).toBeInTheDocument(); + }); + + it('clears a stale mutation error when a new option is selected', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockRejectedValueOnce(new Error('Network Error')); + + renderWidget(); + + /* Trigger an error from a dropdown click. */ + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + + /* Selecting "Specific Student..." should clear it before opening the input. */ + await user.click(await screen.findByRole('button', { expanded: false })); + await user.click(await screen.findByRole('button', { name: 'Specific Student...' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect( + screen.queryByText(/An error has occurred; please try again/), + ).not.toBeInTheDocument(); + }); + + describe('demotion redirect', () => { + /* The route the widget renders at, /course/${COURSE_ID}, is what + * `location.pathname` resolves to inside the hook. We compose tab + * fixtures that either include this path (no redirect) or don't (redirect + * to the first tab the new role can still see). */ + const externalLearning = `http://learning.example/learning/course/${COURSE_ID}`; + const inAppOutline = `https://lms.example/course/${COURSE_ID}/outline`; + const currentPathTab = `https://lms.example/course/${COURSE_ID}`; + + it('uses window.location when the new role lives on a different origin', async () => { + const user = userEvent.setup(); + /* Tabs the demoted user can see: only an external "course home". The + * current path /course/${COURSE_ID} isn't in the list, so we redirect. */ + mockGetCourseHomeCourseMetadata.mockResolvedValue({ + tabs: [{ tabId: 'outline', title: 'Course', url: externalLearning }], + }); + mockPostMasqueradeOptions.mockResolvedValue({ success: true } as api.MasqueradeStatus); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockLocationAssign).toHaveBeenCalledWith(externalLearning)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses react-router navigate when the first remaining tab is in-app', async () => { + const user = userEvent.setup(); + mockGetCourseHomeCourseMetadata.mockResolvedValue({ + tabs: [{ tabId: 'outline', title: 'Outline', url: inAppOutline }], + }); + mockIsClientRoute.mockReturnValue(true); + mockPostMasqueradeOptions.mockResolvedValue({ success: true } as api.MasqueradeStatus); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith( + `/course/${COURSE_ID}/outline`, + { replace: true }, + )); + expect(mockLocationAssign).not.toHaveBeenCalled(); + }); + + it('does not navigate when the current path is still a tab', async () => { + /* The demoted user can still see the page they're on, so the redirect + * is suppressed — but the bar must still refetch its query state. */ + const user = userEvent.setup(); + mockGetCourseHomeCourseMetadata.mockResolvedValue({ + tabs: [{ tabId: 'outline', title: 'Outline', url: currentPathTab }], + }); + mockPostMasqueradeOptions.mockResolvedValue({ success: true } as api.MasqueradeStatus); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockLocationAssign).not.toHaveBeenCalled(); + await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledTimes(2)); + }); + + it('redirects on the bare {success: true} response shape the LMS actually returns', async () => { + /* The POST endpoint returns only `{success: true}` — no `active` or + * `available`. The redirect logic relies on the submitted payload's + * role, not the response, to decide whether to check the new tabs. */ + const user = userEvent.setup(); + mockGetCourseHomeCourseMetadata.mockResolvedValue({ + tabs: [{ tabId: 'outline', title: 'Course', url: externalLearning }], + }); + mockPostMasqueradeOptions.mockResolvedValue({ success: true } as api.MasqueradeStatus); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockLocationAssign).toHaveBeenCalledWith(externalLearning)); + }); + + it('does not navigate when the new identity can still see the current page', async () => { + const user = userEvent.setup(); + /* Picking Staff while alice is masquerading: the tabs API returns the + * staff-visible set, which still includes the current path, so no + * redirect — but the tabs are still fetched. */ + mockGetCourseHomeCourseMetadata.mockResolvedValue({ + tabs: [{ tabId: 'outline', title: 'Outline', url: currentPathTab }], + }); + mockGetMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', userName: 'alice' }, + }); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: defaultActive, /* role: 'staff' */ + }); + + renderWidget(); + /* Toggle shows the active username; click it then click Staff. */ + await user.click(await screen.findByRole('button', { expanded: false })); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); + expect(mockLocationAssign).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when the new identity has no remaining tabs', async () => { + const user = userEvent.setup(); + mockGetCourseHomeCourseMetadata.mockResolvedValue({ tabs: [] }); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + active: { ...defaultActive, role: 'student', groupId: 1, userPartitionId: 50 }, + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); + expect(mockLocationAssign).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when the server returns success: false', async () => { + const user = userEvent.setup(); + mockPostMasqueradeOptions.mockResolvedValue({ + ...defaultResponse, + success: false, + error: 'Tried to masquerade as a deactivated user.', + }); + + renderWidget(); + await user.click(await screen.findByRole('button', { name: 'Staff' })); + await user.click(await screen.findByRole('button', { name: 'Audit' })); + + await waitFor(() => expect(mockPostMasqueradeOptions).toHaveBeenCalled()); + expect(mockLocationAssign).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockGetCourseHomeCourseMetadata).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.tsx b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.tsx new file mode 100644 index 00000000..7c75d151 --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidget.tsx @@ -0,0 +1,47 @@ +import { useId } from 'react'; +import { useIntl, FormattedMessage } from '@openedx/frontend-base'; +import { Dropdown } from '@openedx/paragon'; + +import { MasqueradeUserNameInput } from './MasqueradeUserNameInput'; +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import { useMasqueradeContext } from '../MasqueradeContext'; +import messages from './messages'; + +export function MasqueradeWidget() { + const { formatMessage } = useIntl(); + const inputId = useId(); + const { + active, available, pendingOption, showUserNameInput, + } = useMasqueradeContext(); + + return ( +
+
+ + + + + + {pendingOption?.name + ?? active.groupName + ?? active.userName + ?? formatMessage(messages.titleStaff)} + + + {available.map(option => ( + + ))} + + +
+ {showUserNameInput && ( +
+ + +
+ )} +
+ ); +} diff --git a/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.test.tsx b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.test.tsx new file mode 100644 index 00000000..6a36a85c --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.test.tsx @@ -0,0 +1,96 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import { MasqueradeContext } from '../MasqueradeContext'; +import type { MasqueradeState } from '../hooks'; +import type { ActiveMasqueradeData, MasqueradeOption } from '../data/api'; + +const defaultActive: ActiveMasqueradeData = { + groupId: null, + role: 'staff', + userName: null, + userPartitionId: null, + groupName: null, +}; + +function buildContextValue( + overrides: Partial = {}, +): MasqueradeState { + return { + active: defaultActive, + available: [], + pendingOption: null, + showUserNameInput: false, + userName: '', + setUserName: jest.fn(), + select: jest.fn(), + submitUserName: jest.fn(), + errorMessage: null, + isSubmitting: false, + isLoading: false, + isDenied: false, + isUnreachable: false, + ...overrides, + }; +} + +function renderWithContext( + option: MasqueradeOption, + contextOverrides: Partial = {}, +) { + const contextValue = buildContextValue(contextOverrides); + return { + ...render( + + + , + ), + contextValue, + }; +} + +describe('MasqueradeWidgetOption', () => { + it('renders the active option with the active class', () => { + renderWithContext({ name: 'Staff', role: 'staff' }); + const button = screen.getByRole('button', { name: 'Staff', hidden: true }); + expect(button).toHaveClass('active'); + }); + + it('renders an inactive option without the active class', () => { + renderWithContext({ name: 'Specific Student...', role: 'student', userName: '' }); + const button = screen.getByRole('button', { name: 'Specific Student...', hidden: true }); + expect(button).not.toHaveClass('active'); + }); + + it('calls select with the option when clicked', async () => { + const user = userEvent.setup(); + const select = jest.fn(); + const option: MasqueradeOption = { name: 'Staff', role: 'staff' }; + renderWithContext(option, { select }); + + await user.click(screen.getByRole('button', { name: 'Staff', hidden: true })); + + expect(select).toHaveBeenCalledWith(option); + }); + + it('renders nothing when option name is empty', () => { + const { container } = renderWithContext({ name: '', role: 'staff' }); + expect(container).toBeEmptyDOMElement(); + }); + + it('highlights the pending option even when active does not match', () => { + const pending: MasqueradeOption = { name: 'Specific Student...', role: 'student', userName: '' }; + renderWithContext(pending, { pendingOption: pending }); + const button = screen.getByRole('button', { name: 'Specific Student...', hidden: true }); + expect(button).toHaveClass('active'); + }); + + it('does not highlight a non-pending option while another option is pending', () => { + const pending: MasqueradeOption = { name: 'Specific Student...', role: 'student', userName: '' }; + renderWithContext({ name: 'Staff', role: 'staff' }, { pendingOption: pending }); + const button = screen.getByRole('button', { name: 'Staff', hidden: true }); + expect(button).not.toHaveClass('active'); + }); +}); diff --git a/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.tsx b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.tsx new file mode 100644 index 00000000..5062329a --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/MasqueradeWidgetOption.tsx @@ -0,0 +1,34 @@ +import { Dropdown } from '@openedx/paragon'; + +import { useMasqueradeContext } from '../MasqueradeContext'; +import { isOptionSelected } from '../hooks'; +import type { MasqueradeOption } from '../data/api'; + +interface Props { + option: MasqueradeOption, +} + +export function MasqueradeWidgetOption({ option }: Props) { + const { active, pendingOption, select } = useMasqueradeContext(); + + if (!option.name) { + return null; + } + + /* While a click is pending, the menu mirrors the toggle: only that option + * is highlighted. Once the server confirms (pendingOption clears), the + * active-server-state logic takes over again. */ + const isHighlighted = pendingOption !== null + ? option.name === pendingOption.name + : isOptionSelected(option, active); + + return ( + select(option)} + > + {option.name} + + ); +} diff --git a/shell/header/course-bar/masquerade/masquerade-widget/index.ts b/shell/header/course-bar/masquerade/masquerade-widget/index.ts new file mode 100644 index 00000000..bbaab1d4 --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/index.ts @@ -0,0 +1,3 @@ +import { MasqueradeWidget } from './MasqueradeWidget'; + +export default MasqueradeWidget; diff --git a/shell/header/course-bar/masquerade/masquerade-widget/messages.ts b/shell/header/course-bar/masquerade/masquerade-widget/messages.ts new file mode 100644 index 00000000..95f241d7 --- /dev/null +++ b/shell/header/course-bar/masquerade/masquerade-widget/messages.ts @@ -0,0 +1,31 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + userNameLabel: { + id: 'masquerade-widget.userName.input.label', + defaultMessage: 'Username or email', + description: 'Label for the masquerade username/email input.', + }, + submit: { + id: 'masquerade-widget.userName.submit', + defaultMessage: 'Submit', + description: 'Label for the masquerade username submit button.', + }, + submitting: { + id: 'masquerade-widget.userName.submitting', + defaultMessage: 'Submitting', + description: 'Pending label for the masquerade username submit button.', + }, + titleViewAs: { + id: 'masquerade-widget.view.as', + defaultMessage: 'View this course as:', + description: 'Button to view this course as', + }, + titleStaff: { + id: 'masquerade-widget.staff', + defaultMessage: 'Staff', + description: 'Button Staff', + }, +}); + +export default messages; diff --git a/shell/header/course-bar/masquerade/messages.ts b/shell/header/course-bar/masquerade/messages.ts new file mode 100644 index 00000000..62823820 --- /dev/null +++ b/shell/header/course-bar/masquerade/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from '../../../../runtime'; + +const messages = defineMessages({ + ariaLabel: { + id: 'masqueradeBar.ariaLabel', + defaultMessage: 'Masquerade bar', + description: 'Accessible label identifying the masquerade bar region.', + }, + 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', + }, + failedToLoadOptions: { + id: 'masqueradeBar.error.failedToLoadOptions', + defaultMessage: 'Unable to load masquerade options.', + description: 'Error shown when masquerade options cannot be retrieved from the server.', + }, + noStudentFound: { + id: 'masqueradeBar.error.noStudentFound', + defaultMessage: 'No student with this username or email could be found.', + description: 'Error shown when masquerading by username and the user does not exist.', + }, + genericSubmitError: { + id: 'masqueradeBar.error.genericSubmit', + defaultMessage: 'An error has occurred; please try again.', + description: 'Generic error shown when the masquerade submission fails.', + }, +}); + +export default messages; diff --git a/shell/header/course-navigation-bar/CourseTabsNavigation.test.tsx b/shell/header/course-bar/navigation/CourseTabsNavigation.test.tsx similarity index 93% rename from shell/header/course-navigation-bar/CourseTabsNavigation.test.tsx rename to shell/header/course-bar/navigation/CourseTabsNavigation.test.tsx index 175a9c5e..cb6d6034 100644 --- a/shell/header/course-navigation-bar/CourseTabsNavigation.test.tsx +++ b/shell/header/course-bar/navigation/CourseTabsNavigation.test.tsx @@ -2,13 +2,17 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { IntlProvider } from '../../../runtime/i18n'; +import { IntlProvider } from '../../../../runtime/i18n'; import CourseTabsNavigation from './CourseTabsNavigation'; -import * as service from './data/service'; -import * as utils from './utils'; - -jest.mock('./data/service'); -jest.mock('./utils'); +import * as service from '../data/service'; +import * as utils from '../utils'; + +/* Mock just the network call; keep findActiveTab + queryKey real. */ +jest.mock('../data/service', () => ({ + ...jest.requireActual('../data/service'), + getCourseHomeCourseMetadata: jest.fn(), +})); +jest.mock('../utils'); const mockGetCourseHomeCourseMetadata = service.getCourseHomeCourseMetadata as jest.MockedFunction; const mockIsClientRoute = utils.isClientRoute as jest.MockedFunction; diff --git a/shell/header/course-navigation-bar/CourseTabsNavigation.tsx b/shell/header/course-bar/navigation/CourseTabsNavigation.tsx similarity index 64% rename from shell/header/course-navigation-bar/CourseTabsNavigation.tsx rename to shell/header/course-bar/navigation/CourseTabsNavigation.tsx index 90696393..58ac6d01 100644 --- a/shell/header/course-navigation-bar/CourseTabsNavigation.tsx +++ b/shell/header/course-bar/navigation/CourseTabsNavigation.tsx @@ -1,11 +1,16 @@ import { useMemo } from 'react'; -import { Link, matchPath, useLocation, useParams } from 'react-router-dom'; +import { Link, useLocation, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Slot, useIntl } from '../../../runtime'; -import { CourseTab, getCourseHomeCourseMetadata } from './data/service'; +import { Slot, useIntl } from '../../../../runtime'; +import { + CourseTab, + courseHomeCourseMetadataQueryKey, + findActiveTab, + getCourseHomeCourseMetadata, +} from '../data/service'; import { Container, Nav, Navbar, Skeleton } from '@openedx/paragon'; import messages from './messages'; -import { isClientRoute } from './utils'; +import { isClientRoute } from '../utils'; import './course-tabs-navigation.scss'; interface ResolvedTab extends CourseTab { @@ -13,36 +18,13 @@ interface ResolvedTab extends CourseTab { clientPath: string | null, } -/* - * Returns the tabId of the tab whose pathname is the longest prefix match - * against the current path. Uses react-router's matchPath for segment-aware - * matching. - * - * For example, given tabs with paths /course/ (tabId: "outline") - * and /course/dates/ (tabId: "dates"): - * - * /course/dates/foo -> "dates" (longest prefix match) - * /course/outline -> "outline" - * /courseware -> null (not a segment boundary) - */ -const getActiveTabId = (currentPath: string, tabs: ResolvedTab[]): string | null => { - let best: ResolvedTab | null = null; - for (const tab of tabs) { - const match = matchPath({ path: `${tab.pathname}/*`, end: false }, currentPath); - if (match && (!best || tab.pathname.length > best.pathname.length)) { - best = tab; - } - } - return best?.tabId ?? null; -}; - const CourseTabsNavigation = () => { const location = useLocation(); const { courseId = '' } = useParams(); const intl = useIntl(); const { data = { tabs: [] }, isLoading } = useQuery({ - queryKey: ['org.openedx.frontend.app.header.course-meta', courseId], + queryKey: courseHomeCourseMetadataQueryKey(courseId), queryFn: () => getCourseHomeCourseMetadata(courseId), retry: 2, enabled: !!courseId, @@ -60,7 +42,7 @@ const CourseTabsNavigation = () => { ); const currentTab = useMemo( - () => resolvedTabs.length > 0 ? getActiveTabId(location.pathname, resolvedTabs) : null, + () => (resolvedTabs.length > 0 ? findActiveTab(resolvedTabs, location.pathname)?.tabId ?? null : null), [location.pathname, resolvedTabs] ); diff --git a/shell/header/course-navigation-bar/course-tabs-navigation.scss b/shell/header/course-bar/navigation/course-tabs-navigation.scss similarity index 100% rename from shell/header/course-navigation-bar/course-tabs-navigation.scss rename to shell/header/course-bar/navigation/course-tabs-navigation.scss diff --git a/shell/header/course-navigation-bar/messages.ts b/shell/header/course-bar/navigation/messages.ts similarity index 83% rename from shell/header/course-navigation-bar/messages.ts rename to shell/header/course-bar/navigation/messages.ts index ac84b866..86d3085e 100644 --- a/shell/header/course-navigation-bar/messages.ts +++ b/shell/header/course-bar/navigation/messages.ts @@ -1,4 +1,4 @@ -import { defineMessages } from '../../../runtime'; +import { defineMessages } from '../../../../runtime'; const messages = defineMessages({ courseMaterial: { diff --git a/shell/header/course-bar/utils.test.ts b/shell/header/course-bar/utils.test.ts new file mode 100644 index 00000000..7a65a286 --- /dev/null +++ b/shell/header/course-bar/utils.test.ts @@ -0,0 +1,128 @@ +import { isClientRoute, isCourseBarMasqueradeRoute, isCourseBarRoute } from './utils'; +import * as runtime from '../../../runtime'; +import { providesCourseBarMasqueradeRolesId, providesCourseBarRolesId } from '../constants'; + +jest.mock('../../../runtime'); + +const mockGetActiveRoles = runtime.getActiveRoles as jest.MockedFunction; +const mockGetProvidesAsStrings = runtime.getProvidesAsStrings as jest.MockedFunction; +const mockGetUrlByRouteRole = runtime.getUrlByRouteRole as jest.MockedFunction; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +/* Stub `getProvidesAsStrings` to return per-key role lists. */ +function provideRoles(byKey: { courseBar?: string[], masquerade?: string[] }) { + mockGetProvidesAsStrings.mockImplementation(id => { + if (id === providesCourseBarRolesId) { + return byKey.courseBar ?? []; + } + if (id === providesCourseBarMasqueradeRolesId) { + return byKey.masquerade ?? []; + } + return []; + }); +} + +describe('isCourseBarRoute', () => { + it('returns true when a provided role is active', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.instructorDashboard'] }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); + + expect(isCourseBarRoute()).toBe(true); + }); + + it('returns false when no provided role is active', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.instructorDashboard'] }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.learning']); + + expect(isCourseBarRoute()).toBe(false); + }); + + it('returns false when no providers exist', () => { + provideRoles({}); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); + + expect(isCourseBarRoute()).toBe(false); + }); +}); + +describe('isCourseBarMasqueradeRoute', () => { + it('returns true when a role appears in both opt-ins and is active', () => { + provideRoles({ + courseBar: ['org.openedx.frontend.role.instructorDashboard'], + masquerade: ['org.openedx.frontend.role.instructorDashboard'], + }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); + + expect(isCourseBarMasqueradeRoute()).toBe(true); + }); + + it('returns false when the active role only opted into the course bar', () => { + provideRoles({ + courseBar: ['org.openedx.frontend.role.instructorDashboard'], + }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); + + expect(isCourseBarMasqueradeRoute()).toBe(false); + }); + + it('returns false when the active role only opted into masquerade', () => { + /* Masquerade is a refinement of the course bar; a role that didn't opt + * into the course bar can't enable masquerade on its own. */ + provideRoles({ + masquerade: ['org.openedx.frontend.role.instructorDashboard'], + }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); + + expect(isCourseBarMasqueradeRoute()).toBe(false); + }); + + it('returns false when no provided role is active', () => { + provideRoles({ + courseBar: ['org.openedx.frontend.role.instructorDashboard'], + masquerade: ['org.openedx.frontend.role.instructorDashboard'], + }); + mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.learning']); + + expect(isCourseBarMasqueradeRoute()).toBe(false); + }); +}); + +describe('isClientRoute', () => { + it('matches a pathname under a static route path', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.learning'] }); + mockGetUrlByRouteRole.mockReturnValue('/course'); + + expect(isClientRoute('/course/outline')).toBe(true); + }); + + it('matches a pathname under a parameterized route path', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.instructorDashboard'] }); + mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId'); + + expect(isClientRoute('/instructor-dashboard/course-v1:edX+DemoX+Demo')).toBe(true); + }); + + it('does not match a pathname outside the route prefix', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.instructorDashboard'] }); + mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId'); + + expect(isClientRoute('/courses/some-course/instructor')).toBe(false); + }); + + it('returns false for external routes', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.learning'] }); + mockGetUrlByRouteRole.mockReturnValue('https://external.example.com/course'); + + expect(isClientRoute('/course/outline')).toBe(false); + }); + + it('returns false when role has no matching route', () => { + provideRoles({ courseBar: ['org.openedx.frontend.role.learning'] }); + mockGetUrlByRouteRole.mockReturnValue(null); + + expect(isClientRoute('/course/outline')).toBe(false); + }); +}); diff --git a/shell/header/course-bar/utils.ts b/shell/header/course-bar/utils.ts new file mode 100644 index 00000000..0af1413e --- /dev/null +++ b/shell/header/course-bar/utils.ts @@ -0,0 +1,58 @@ +import { matchPath } from 'react-router-dom'; +import { getActiveRoles, getProvidesAsStrings, getUrlByRouteRole } from '../../../runtime'; +import { providesCourseBarMasqueradeRolesId, providesCourseBarRolesId } from '../constants'; + +/* + * Collects route role strings from all apps that opted into the course bar. + * Each app declares its roles as a string array: + * + * provides: { + * [providesCourseBarRolesId]: ['org.openedx.frontend.role.learning'], + * } + */ +function getCourseBarRoles(): string[] { + return getProvidesAsStrings(providesCourseBarRolesId); +} + +/* + * Course-bar roles that additionally enable the masquerade widget. Apps opt + * in to the masquerade slot per-role on top of their course-bar declaration: + * + * provides: { + * [providesCourseBarRolesId]: ['org.openedx.frontend.role.learning'], + * [providesCourseBarMasqueradeRolesId]: ['org.openedx.frontend.role.learning'], + * } + * + * A role only present in the masquerade list (without a matching course-bar + * declaration) is ignored — masquerade is a refinement of the course bar, + * not an independent feature. + */ +function getCourseBarMasqueradeRoles(): string[] { + const courseBarRoles = new Set(getCourseBarRoles()); + return getProvidesAsStrings(providesCourseBarMasqueradeRolesId) + .filter(role => courseBarRoles.has(role)); +} + +export function isCourseBarRoute(): boolean { + const activeRoles = getActiveRoles(); + return getCourseBarRoles().some(role => activeRoles.includes(role)); +} + +export function isCourseBarMasqueradeRoute(): boolean { + const activeRoles = getActiveRoles(); + return getCourseBarMasqueradeRoles().some(role => activeRoles.includes(role)); +} + +/* + * Whether `pathname` is served by an in-app route (a registered route role + * with a relative URL). Drives the choice between react-router navigation + * and a hard `window.location.assign` when the course bar redirects. + */ +export function isClientRoute(pathname: string): boolean { + return getCourseBarRoles().some(role => { + const routePath = getUrlByRouteRole(role); + return routePath !== null + && routePath.startsWith('/') + && matchPath({ path: routePath, end: false }, pathname) !== null; + }); +} diff --git a/shell/header/course-navigation-bar/utils.test.ts b/shell/header/course-navigation-bar/utils.test.ts deleted file mode 100644 index 87fb98cc..00000000 --- a/shell/header/course-navigation-bar/utils.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { isClientRoute, isCourseNavigationRoute } from './utils'; -import * as runtime from '../../../runtime'; - -jest.mock('../../../runtime'); - -const mockGetActiveRoles = runtime.getActiveRoles as jest.MockedFunction; -const mockGetProvidesAsStrings = runtime.getProvidesAsStrings as jest.MockedFunction; -const mockGetUrlByRouteRole = runtime.getUrlByRouteRole as jest.MockedFunction; - -describe('isCourseNavigationRoute', () => { - it('returns true when a provided role is active', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - - expect(isCourseNavigationRoute()).toBe(true); - }); - - it('returns false when no provided roles are active', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.learning']); - - expect(isCourseNavigationRoute()).toBe(false); - }); - - it('returns false when no providers exist', () => { - mockGetProvidesAsStrings.mockReturnValue([]); - mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - - expect(isCourseNavigationRoute()).toBe(false); - }); -}); - -describe('isClientRoute', () => { - it('matches a pathname under a static route path', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.learning']); - mockGetUrlByRouteRole.mockReturnValue('/course'); - - expect(isClientRoute('/course/outline')).toBe(true); - }); - - it('matches a pathname under a parameterized route path', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId'); - - expect(isClientRoute('/instructor-dashboard/course-v1:edX+DemoX+Demo')).toBe(true); - }); - - it('does not match a pathname outside the route prefix', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.instructorDashboard']); - mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId'); - - expect(isClientRoute('/courses/some-course/instructor')).toBe(false); - }); - - it('returns false for external routes', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.learning']); - mockGetUrlByRouteRole.mockReturnValue('https://external.example.com/course'); - - expect(isClientRoute('/course/outline')).toBe(false); - }); - - it('returns false when role has no matching route', () => { - mockGetProvidesAsStrings.mockReturnValue(['org.openedx.frontend.role.learning']); - mockGetUrlByRouteRole.mockReturnValue(null); - - expect(isClientRoute('/course/outline')).toBe(false); - }); -}); diff --git a/shell/header/course-navigation-bar/utils.ts b/shell/header/course-navigation-bar/utils.ts deleted file mode 100644 index b3ef99c5..00000000 --- a/shell/header/course-navigation-bar/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { matchPath } from 'react-router-dom'; -import { getActiveRoles, getProvidesAsStrings, getUrlByRouteRole } from '../../../runtime'; -import { providesCourseNavigationRolesId } 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: { - * [providesCourseNavigationRolesId]: ['org.openedx.frontend.role.learning'], - * } - */ -function getCourseNavigationBarRoles(): string[] { - return getProvidesAsStrings(providesCourseNavigationRolesId); -} - -export function isCourseNavigationRoute(): boolean { - const activeRoles = getActiveRoles(); - return getCourseNavigationBarRoles().some(role => activeRoles.includes(role)); -} - -export function isClientRoute(pathname: string): boolean { - return getCourseNavigationBarRoles().some(role => { - const routePath = getUrlByRouteRole(role); - return routePath !== null - && routePath.startsWith('/') - && matchPath({ path: routePath, end: false }, pathname) !== null; - }); -} diff --git a/shell/header/index.ts b/shell/header/index.ts index b04ff638..38acdda9 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 { providesCourseBarRolesId, providesCourseBarMasqueradeRolesId } from './constants'; export { default as Header } from './Header'; export { default as HelpButton } from './HelpButton'; export { helpButtonSlotOperation, helpWidgetId } from './helpButtonSlotOperation'; diff --git a/shell/index.ts b/shell/index.ts index af106a72..a1addb0c 100644 --- a/shell/index.ts +++ b/shell/index.ts @@ -1,8 +1,16 @@ 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 { + Header, + HelpButton, + headerApp, + helpButtonSlotOperation, + helpWidgetId, + providesCourseBarRolesId, + providesCourseBarMasqueradeRolesId, +} from './header'; export { homeRole, providesChromelessRolesId } from './constants'; export { default as LinkMenuItem } from './menus/LinkMenuItem'; export { default as NavDropdownMenuSlot } from './menus/NavDropdownMenuSlot'; +export { default as shellApp } from './app'; diff --git a/shell/site.config.dev.tsx b/shell/site.config.dev.tsx index 75c579e8..f8f3be76 100644 --- a/shell/site.config.dev.tsx +++ b/shell/site.config.dev.tsx @@ -43,6 +43,7 @@ const siteConfig: SiteConfig = { // API URLs lmsBaseUrl: 'http://local.openedx.io:8000', + cmsBaseUrl: 'http://studio.local.openedx.io:8001', runtimeConfigJsonUrl: '/api/frontend_site_config/v1/', }; diff --git a/types.ts b/types.ts index 04bbb094..62113472 100644 --- a/types.ts +++ b/types.ts @@ -63,6 +63,9 @@ export interface OptionalSiteConfig { // Site environment environment: EnvironmentTypes, + // Backends + cmsBaseUrl: string, + // Apps, routes, and URLs apps: App[], basename: string,