Skip to content

Commit 2646835

Browse files
arbrandesdiana-villalvazo-wgujesusbalderramawguclaude
committed
feat: Masquerade bar
Adds a masquerade bar to the shell so course staff can view a course as a different role (Staff, a group like Audit) or as a specific learner. Apps opt in by declaring a route role through the masqueradeBarRoles provides ID; the bar renders in its slot whenever the active route matches. 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 2646835

24 files changed

Lines changed: 1168 additions & 3 deletions

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import MobileNavLinks from './mobile/MobileNavLinks';
1313

1414
import messages from '../Shell.messages';
1515
import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation';
16+
import MasqueradeBar from './masquerade-bar/MasqueradeBar';
1617
import { isCourseNavigationRoute } from './course-navigation-bar/utils';
18+
import { isMasqueradeBarRoute } from './masquerade-bar/utils';
1719
import { appId } from './constants';
1820
import './app.scss';
1921

@@ -147,6 +149,15 @@ const config: App = {
147149
condition: {
148150
callback: () => isCourseNavigationRoute(),
149151
}
152+
},
153+
{
154+
slotId: 'org.openedx.frontend.slot.header.masqueradeBar.v1',
155+
id: 'org.openedx.frontend.widget.header.masqueradeBar.v1',
156+
op: WidgetOperationTypes.APPEND,
157+
component: MasqueradeBar,
158+
condition: {
159+
callback: () => isMasqueradeBarRoute(),
160+
}
150161
}
151162
]
152163
};

shell/header/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const appId = 'org.openedx.frontend.app.header';
22
export const providesCourseNavigationRolesId = 'org.openedx.frontend.provides.courseNavigationRoles.v1';
3+
export const providesMasqueradeBarRolesId = 'org.openedx.frontend.provides.masqueradeBarRoles.v1';

shell/header/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { default as headerApp } from './app';
2-
export { providesCourseNavigationRolesId } from './constants';
2+
export { providesCourseNavigationRolesId, providesMasqueradeBarRolesId } from './constants';
33
export { default as Header } from './Header';
44
export { default as HelpButton } from './HelpButton';
55
export { helpButtonSlotOperation, helpWidgetId } from './helpButtonSlotOperation';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen, waitFor } 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+
courseKey: COURSE_ID,
22+
groupId: null,
23+
role: 'staff',
24+
userName: null,
25+
userPartitionId: null,
26+
groupName: null,
27+
},
28+
available: [
29+
{ name: 'Staff', role: 'staff' },
30+
{ name: 'Specific Student...', role: 'student', userName: '' },
31+
],
32+
};
33+
34+
function createTestQueryClient() {
35+
return new QueryClient({
36+
defaultOptions: {
37+
queries: { retry: false },
38+
mutations: { retry: false },
39+
},
40+
});
41+
}
42+
43+
function renderMasqueradeBar(
44+
path = `/course/${COURSE_ID}/unit/${UNIT_ID}`,
45+
cmsBaseUrl = '',
46+
) {
47+
mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse);
48+
setSiteConfig({ ...getSiteConfig(), cmsBaseUrl });
49+
50+
const queryClient = createTestQueryClient();
51+
return render(
52+
<QueryClientProvider client={queryClient}>
53+
<IntlProvider locale="en">
54+
<MemoryRouter initialEntries={[path]}>
55+
<Routes>
56+
<Route path="/course/:courseId/unit/:unitId" element={<MasqueradeBar />} />
57+
<Route path="/course/:courseId" element={<MasqueradeBar />} />
58+
</Routes>
59+
</MemoryRouter>
60+
</IntlProvider>
61+
</QueryClientProvider>,
62+
);
63+
}
64+
65+
describe('MasqueradeBar', () => {
66+
beforeEach(() => {
67+
jest.resetAllMocks();
68+
});
69+
70+
it('renders the masquerade widget', async () => {
71+
renderMasqueradeBar();
72+
73+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID));
74+
expect(screen.getByRole('region', { name: /masquerade bar/i })).toBeInTheDocument();
75+
expect(screen.getByText('View this course as:')).toBeInTheDocument();
76+
});
77+
78+
it('displays Studio link when cmsBaseUrl is configured', async () => {
79+
renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, 'http://localhost:18010');
80+
81+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
82+
83+
expect(screen.getByText('View course in:')).toBeInTheDocument();
84+
const studioLink = screen.getByRole('link', { name: 'Studio' });
85+
expect(studioLink).toHaveAttribute('href', `http://localhost:18010/container/${UNIT_ID}`);
86+
});
87+
88+
it('builds Studio URL with courseId when unitId is not in the route', async () => {
89+
renderMasqueradeBar(`/course/${COURSE_ID}`, 'http://localhost:18010');
90+
91+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
92+
93+
const studioLink = screen.getByRole('link', { name: 'Studio' });
94+
expect(studioLink).toHaveAttribute('href', `http://localhost:18010/course/${COURSE_ID}`);
95+
});
96+
97+
it('does not display Studio link when cmsBaseUrl is not configured', async () => {
98+
renderMasqueradeBar(`/course/${COURSE_ID}/unit/${UNIT_ID}`, '');
99+
100+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
101+
102+
expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
103+
expect(screen.queryByRole('link', { name: 'Studio' })).not.toBeInTheDocument();
104+
});
105+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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/MasqueradeWidget';
7+
import { formatErrorMessage, useMasqueradeWidget } 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 widget = useMasqueradeWidget(courseId);
15+
const { errorMessage, isQueryFailure } = widget;
16+
17+
return (
18+
<MasqueradeContext.Provider value={widget}>
19+
<div role="region" aria-label={formatMessage(messages.ariaLabel)}>
20+
<div className="bg-primary text-white">
21+
<Container fluid size="xl">
22+
<div className="py-3 d-md-flex justify-content-end align-items-start">
23+
{!isQueryFailure && (
24+
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
25+
<MasqueradeWidget />
26+
</div>
27+
)}
28+
<StudioLink courseId={courseId} unitId={unitId} />
29+
</div>
30+
</Container>
31+
</div>
32+
{errorMessage && (
33+
<Container fluid size="xl" className="mt-3">
34+
<Alert variant="warning" role="alert" dismissible={false} className="mb-0">
35+
{formatErrorMessage(formatMessage, errorMessage)}
36+
</Alert>
37+
</Container>
38+
)}
39+
</div>
40+
</MasqueradeContext.Provider>
41+
);
42+
}
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 { UseMasqueradeWidgetReturn } from './hooks';
4+
5+
export const MasqueradeContext = createContext<UseMasqueradeWidgetReturn | null>(null);
6+
7+
export function useMasqueradeContext(): UseMasqueradeWidgetReturn {
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useIntl, FormattedMessage, getSiteConfig } from '@openedx/frontend-base';
2+
3+
import messages from './messages';
4+
5+
interface Props {
6+
courseId?: string,
7+
unitId?: string,
8+
}
9+
10+
function buildStudioUrl(courseId?: string, unitId?: string): string | null {
11+
const base = getSiteConfig().cmsBaseUrl;
12+
if (!base) {
13+
return null;
14+
}
15+
if (unitId) {
16+
return `${base}/container/${unitId}`;
17+
}
18+
if (courseId) {
19+
return `${base}/course/${courseId}`;
20+
}
21+
return null;
22+
}
23+
24+
export function StudioLink({ courseId, unitId }: Props) {
25+
const { formatMessage } = useIntl();
26+
const url = buildStudioUrl(courseId, unitId);
27+
28+
if (!url) {
29+
return null;
30+
}
31+
32+
return (
33+
<>
34+
<hr className="border-light" />
35+
<span className="mr-2 mt-1 col-form-label">
36+
<FormattedMessage {...messages.titleViewCourseIn} />
37+
</span>
38+
<span className="mx-1 my-1">
39+
<a className="btn btn-inverse-outline-primary" href={url}>
40+
{formatMessage(messages.titleStudio)}
41+
</a>
42+
</span>
43+
</>
44+
);
45+
}
46+
47+
export default StudioLink;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { getSiteConfig, camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
2+
3+
export type Role = 'staff' | 'student';
4+
5+
export interface ActiveMasqueradeData {
6+
courseKey: string,
7+
role: Role,
8+
userName: string | null,
9+
userPartitionId: number | null,
10+
groupId: number | null,
11+
groupName: string | null,
12+
}
13+
14+
export interface MasqueradeOption {
15+
name: string,
16+
role: Role,
17+
userName?: string,
18+
groupId?: number,
19+
userPartitionId?: number,
20+
}
21+
22+
export interface MasqueradeStatus {
23+
success: boolean,
24+
error?: string,
25+
active: ActiveMasqueradeData,
26+
available: MasqueradeOption[],
27+
}
28+
29+
export interface Payload {
30+
role?: Role,
31+
user_name?: string,
32+
group_id?: number,
33+
user_partition_id?: number,
34+
}
35+
36+
export async function getMasqueradeOptions(courseId: string): Promise<MasqueradeStatus> {
37+
const url = new URL(`${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`);
38+
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
39+
return camelCaseObject(data);
40+
}
41+
42+
export async function postMasqueradeOptions(courseId: string, payload: Payload): Promise<MasqueradeStatus> {
43+
const url = new URL(`${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`);
44+
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
45+
return camelCaseObject(data);
46+
}

0 commit comments

Comments
 (0)