Skip to content

Commit 059e3a9

Browse files
arbrandesdiana-villalvazo-wgujesusbalderramawguclaude
committed
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 <diana.villalvazo@wgu.edu> Co-Authored-By: Jesus Balderrama <jesus.balderrama.wgu@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a517f39 commit 059e3a9

33 files changed

Lines changed: 1657 additions & 149 deletions

docs/decisions/0013-app-provides-for-inter-app-configuration.rst

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,28 +99,33 @@ perspective. Consuming apps bear the responsibility of defining, documenting,
9999
and validating the shape of the data they expect. This is acceptable because
100100
the data is, by definition, outside frontend-base's domain.
101101

102-
Course navigation bar example
103-
-----------------------------
102+
Course bar example
103+
------------------
104104

105105
As a concrete illustration, the Instructor Dashboard app could declare::
106106

107107
const config: App = {
108108
appId: 'org.openedx.frontend.app.instructorDashboard',
109109
provides: {
110-
'org.openedx.frontend.provides.courseNavigationRoles.v1': [
110+
'org.openedx.frontend.provides.courseBarRoles.v1': [
111+
'org.openedx.frontend.role.instructorDashboard',
112+
],
113+
'org.openedx.frontend.provides.courseBarMasqueradeRoles.v1': [
111114
'org.openedx.frontend.role.instructorDashboard',
112115
],
113116
},
114117
routes: [...],
115118
slots: [...],
116119
};
117120

118-
The header's course navigation bar widget collects ``provides`` entries keyed
119-
to the course navigation roles identifier from all registered apps. It expects
120-
the provided values to be role identifiers, from which it determines both when
121-
to render the navigation bar (by checking ``getActiveRoles()``) and which tab
122-
URLs can be navigated client-side (by resolving roles to route paths via
123-
``getUrlByRouteRole()``).
121+
The header's course bar has two parts: the tab navigation, which renders for
122+
any role declared under ``courseBarRoles``, and the masquerade widget, which
123+
additionally requires the role to be declared under ``courseBarMasqueradeRoles``.
124+
Masquerade is therefore a refinement of the course bar: a role only present
125+
in the masquerade list (without a matching course-bar declaration) is
126+
ignored. The course-bar role list also supplies which tab URLs can be
127+
navigated client-side, by resolving roles to route paths via
128+
``getUrlByRouteRole()``.
124129

125130

126131
Rejected alternatives

runtime/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ let siteConfig: SiteConfig = {
123123

124124
// Optional
125125
environment: EnvironmentTypes.PRODUCTION,
126+
cmsBaseUrl: '',
126127
apps: [],
127128
externalRoutes: [],
128129
externalLinkUrlOverrides: [],

shell/header/Header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default function Header() {
1414
<Slot id="org.openedx.frontend.slot.header.mobile.v1" />
1515
</nav>
1616
</header>
17+
<Slot id="org.openedx.frontend.slot.header.masqueradeBar.v1" />
1718
<Slot id="org.openedx.frontend.slot.header.courseNavigationBar.v1" />
1819
</>
1920
);

shell/header/app.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import MobileLayout from './mobile/MobileLayout';
1212
import MobileNavLinks from './mobile/MobileNavLinks';
1313

1414
import messages from '../Shell.messages';
15-
import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation';
16-
import { isCourseNavigationRoute } from './course-navigation-bar/utils';
15+
import CourseTabsNavigation from './course-bar/navigation/CourseTabsNavigation';
16+
import MasqueradeBar from './course-bar/masquerade/MasqueradeBar';
17+
import { isCourseBarMasqueradeRoute, isCourseBarRoute } from './course-bar/utils';
1718
import { appId } from './constants';
1819
import './app.scss';
1920

@@ -145,7 +146,16 @@ const config: App = {
145146
op: WidgetOperationTypes.APPEND,
146147
component: CourseTabsNavigation,
147148
condition: {
148-
callback: () => isCourseNavigationRoute(),
149+
callback: () => isCourseBarRoute(),
150+
}
151+
},
152+
{
153+
slotId: 'org.openedx.frontend.slot.header.masqueradeBar.v1',
154+
id: 'org.openedx.frontend.widget.header.masqueradeBar.v1',
155+
op: WidgetOperationTypes.APPEND,
156+
component: MasqueradeBar,
157+
condition: {
158+
callback: () => isCourseBarMasqueradeRoute(),
149159
}
150160
}
151161
]

shell/header/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const appId = 'org.openedx.frontend.app.header';
2-
export const providesCourseNavigationRolesId = 'org.openedx.frontend.provides.courseNavigationRoles.v1';
2+
export const providesCourseBarRolesId = 'org.openedx.frontend.provides.courseBarRoles.v1';
3+
export const providesCourseBarMasqueradeRolesId = 'org.openedx.frontend.provides.courseBarMasqueradeRoles.v1';

shell/header/course-navigation-bar/data/service.ts renamed to shell/header/course-bar/data/service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { matchPath } from 'react-router-dom';
12
import { getSiteConfig, getAuthenticatedHttpClient, camelCaseObject } from '../../../../runtime';
23

34
// Raw API response from /api/course_home/course_metadata/
@@ -37,3 +38,25 @@ export async function getCourseHomeCourseMetadata(courseId: string): Promise<Cou
3738

3839
return normalizeCourseHomeCourseMetadata(data);
3940
}
41+
42+
export function courseHomeCourseMetadataQueryKey(courseId: string): [string, string] {
43+
return ['org.openedx.frontend.app.header.courseMeta', courseId];
44+
}
45+
46+
/*
47+
* Returns the tab whose URL pathname is the longest prefix match against
48+
* `pathname`, or null if none match. Used by the navigation bar to mark the
49+
* active tab and by the masquerade redirect to decide whether the demoted
50+
* user can still see the page they're on.
51+
*/
52+
export function findActiveTab(tabs: CourseTab[], pathname: string): CourseTab | null {
53+
let best: { tab: CourseTab, length: number } | null = null;
54+
for (const tab of tabs) {
55+
const tabPathname = new URL(tab.url).pathname;
56+
const match = matchPath({ path: `${tabPathname}/*`, end: false }, pathname);
57+
if (match && (!best || tabPathname.length > best.length)) {
58+
best = { tab, length: tabPathname.length };
59+
}
60+
}
61+
return best?.tab ?? null;
62+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
4+
import { IntlProvider } from 'react-intl';
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6+
import { setSiteConfig, getSiteConfig } from '@openedx/frontend-base';
7+
8+
import MasqueradeBar from './MasqueradeBar';
9+
import * as api from './data/api';
10+
11+
jest.mock('./data/api');
12+
13+
const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction<typeof api.getMasqueradeOptions>;
14+
15+
const COURSE_ID = 'course-v1:edX+DemoX+Demo';
16+
const UNIT_ID = 'block-v1:edX+DemoX+Demo+type@vertical+block@abc123';
17+
18+
const defaultMasqueradeResponse: api.MasqueradeStatus = {
19+
success: true,
20+
active: {
21+
groupId: null,
22+
role: 'staff',
23+
userName: null,
24+
userPartitionId: null,
25+
groupName: null,
26+
},
27+
available: [
28+
{ name: 'Staff', role: 'staff' },
29+
{ name: 'Specific Student...', role: 'student', userName: '' },
30+
],
31+
};
32+
33+
function createTestQueryClient() {
34+
return new QueryClient({
35+
defaultOptions: {
36+
queries: { retry: false },
37+
mutations: { retry: false },
38+
},
39+
});
40+
}
41+
42+
function renderMasqueradeBar(
43+
path = `/course/${COURSE_ID}/unit/${UNIT_ID}`,
44+
cmsBaseUrl = '',
45+
) {
46+
mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse);
47+
setSiteConfig({ ...getSiteConfig(), cmsBaseUrl });
48+
49+
const queryClient = createTestQueryClient();
50+
return render(
51+
<QueryClientProvider client={queryClient}>
52+
<IntlProvider locale="en">
53+
<MemoryRouter initialEntries={[path]}>
54+
<Routes>
55+
<Route path="/course/:courseId/unit/:unitId" element={<MasqueradeBar />} />
56+
<Route path="/course/:courseId" element={<MasqueradeBar />} />
57+
</Routes>
58+
</MemoryRouter>
59+
</IntlProvider>
60+
</QueryClientProvider>,
61+
);
62+
}
63+
64+
describe('MasqueradeBar', () => {
65+
beforeEach(() => {
66+
jest.resetAllMocks();
67+
});
68+
69+
it('renders the masquerade widget', async () => {
70+
renderMasqueradeBar();
71+
72+
expect(await screen.findByRole('region', { name: /masquerade bar/i })).toBeInTheDocument();
73+
expect(screen.getByText('View this course as:')).toBeInTheDocument();
74+
});
75+
76+
it('displays Studio link when cmsBaseUrl is configured', async () => {
77+
renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, 'http://localhost:18010');
78+
79+
const studioLink = await screen.findByRole('link', { name: 'Studio' });
80+
expect(screen.getByText('View course in:')).toBeInTheDocument();
81+
expect(studioLink).toHaveAttribute('href', `http://localhost:18010/container/${UNIT_ID}`);
82+
});
83+
84+
it('builds Studio URL with courseId when unitId is not in the route', async () => {
85+
renderMasqueradeBar(`/course/${COURSE_ID}`, 'http://localhost:18010');
86+
87+
const studioLink = await screen.findByRole('link', { name: 'Studio' });
88+
expect(studioLink).toHaveAttribute('href', `http://localhost:18010/course/${COURSE_ID}`);
89+
});
90+
91+
it('does not display Studio link when cmsBaseUrl is not configured', async () => {
92+
renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, '');
93+
94+
/* Wait until the bar is visible before asserting the link's absence. */
95+
await screen.findByRole('region', { name: /masquerade bar/i });
96+
expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
97+
expect(screen.queryByRole('link', { name: 'Studio' })).not.toBeInTheDocument();
98+
});
99+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import { Alert, Container } from '@openedx/paragon';
3+
import { useParams } from 'react-router-dom';
4+
5+
import { MasqueradeContext } from './MasqueradeContext';
6+
import MasqueradeWidget from './masquerade-widget';
7+
import { formatErrorMessage, useMasqueradeState } from './hooks';
8+
import StudioLink from './StudioLink';
9+
import messages from './messages';
10+
11+
export default function MasqueradeBar() {
12+
const { formatMessage } = useIntl();
13+
const { courseId = '', unitId = '' } = useParams();
14+
const masquerade = useMasqueradeState(courseId);
15+
const {
16+
errorMessage, isLoading, isDenied, isUnreachable,
17+
} = masquerade;
18+
19+
/* Render nothing while we wait for the first response, and when the server
20+
* tells us this user can't masquerade. Other failures fall through to a
21+
* partial bar plus an alert. */
22+
if (isLoading || isDenied) {
23+
return null;
24+
}
25+
26+
return (
27+
<MasqueradeContext.Provider value={masquerade}>
28+
<div role="region" aria-label={formatMessage(messages.ariaLabel)}>
29+
<div className="bg-primary text-white">
30+
<Container fluid size="xl">
31+
<div className="py-3 d-md-flex justify-content-end align-items-start">
32+
{!isUnreachable && (
33+
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
34+
<MasqueradeWidget />
35+
</div>
36+
)}
37+
<StudioLink courseId={courseId} unitId={unitId} />
38+
</div>
39+
</Container>
40+
</div>
41+
{errorMessage && (
42+
<Container fluid size="xl" className="mt-3">
43+
<Alert variant="warning" role="alert" dismissible={false} className="mb-0">
44+
{formatErrorMessage(formatMessage, errorMessage)}
45+
</Alert>
46+
</Container>
47+
)}
48+
</div>
49+
</MasqueradeContext.Provider>
50+
);
51+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createContext, useContext } from 'react';
2+
3+
import type { MasqueradeState } from './hooks';
4+
5+
export const MasqueradeContext = createContext<MasqueradeState | null>(null);
6+
7+
export function useMasqueradeContext(): MasqueradeState {
8+
const context = useContext(MasqueradeContext);
9+
if (context === null) {
10+
throw new Error('useMasqueradeContext must be used within a MasqueradeContext.Provider');
11+
}
12+
return context;
13+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useIntl, FormattedMessage, getSiteConfig } from '@openedx/frontend-base';
2+
import { Button } from '@openedx/paragon';
3+
4+
import messages from './messages';
5+
6+
interface Props {
7+
courseId?: string,
8+
unitId?: string,
9+
}
10+
11+
function buildStudioUrl(courseId?: string, unitId?: string): string | null {
12+
const base = getSiteConfig().cmsBaseUrl;
13+
if (!base) {
14+
return null;
15+
}
16+
if (unitId) {
17+
return `${base}/container/${unitId}`;
18+
}
19+
if (courseId) {
20+
return `${base}/course/${courseId}`;
21+
}
22+
return null;
23+
}
24+
25+
export function StudioLink({ courseId, unitId }: Props) {
26+
const { formatMessage } = useIntl();
27+
const url = buildStudioUrl(courseId, unitId);
28+
29+
if (!url) {
30+
return null;
31+
}
32+
33+
return (
34+
<>
35+
<hr className="border-light" />
36+
<span className="mr-2 mt-1 col-form-label">
37+
<FormattedMessage {...messages.titleViewCourseIn} />
38+
</span>
39+
<span className="mx-1 my-1">
40+
<Button variant="inverse-outline-primary" href={url}>
41+
{formatMessage(messages.titleStudio)}
42+
</Button>
43+
</span>
44+
</>
45+
);
46+
}
47+
48+
export default StudioLink;

0 commit comments

Comments
 (0)