Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions docs/decisions/0013-app-provides-for-inter-app-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,28 +99,33 @@ 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',
],
},
routes: [...],
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
Expand Down
1 change: 1 addition & 0 deletions runtime/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ let siteConfig: SiteConfig = {

// Optional
environment: EnvironmentTypes.PRODUCTION,
cmsBaseUrl: '',
Comment thread
brian-smith-tcril marked this conversation as resolved.
apps: [],
externalRoutes: [],
externalLinkUrlOverrides: [],
Expand Down
1 change: 1 addition & 0 deletions shell/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function Header() {
<Slot id="org.openedx.frontend.slot.header.mobile.v1" />
</nav>
</header>
<Slot id="org.openedx.frontend.slot.header.masqueradeBar.v1" />
Copy link
Copy Markdown
Contributor Author

@arbrandes arbrandes May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a course masquerade bar, innit? Like its sibling, the course navigation bar. Maybe we should rename it. Probably. Almost sure of it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also makes me think: the right home for these two is probably-almost-100%-sure not here, but in frontend-app-learning... once that gets converted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into this from the other side. I noticed that the bar requires a courseId but doesn't fully guard against one not being there.

My question was "does this need to be course-specific?" I could imagine wanting to masquerade for learner dash or something (not something the backend currently supports, but I figure from a frontend perspective it could be the same widget)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered the Learner Dash angle when I first reviewed the original PR. It turns out they're very different beasts: not worth unifying. In other words, this is really-really just a course masquerade bar. Not least of which because it requires a courseId.

<Slot id="org.openedx.frontend.slot.header.courseNavigationBar.v1" />
</>
);
Expand Down
16 changes: 13 additions & 3 deletions shell/header/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(),
}
}
]
Expand Down
3 changes: 2 additions & 1 deletion shell/header/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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/
Expand Down Expand Up @@ -37,3 +38,25 @@ export async function getCourseHomeCourseMetadata(courseId: string): Promise<Cou

return normalizeCourseHomeCourseMetadata(data);
}

export function courseHomeCourseMetadataQueryKey(courseId: string): [string, string] {
return ['org.openedx.frontend.app.header.courseMeta', courseId];
}

/*
* Returns the tab whose URL pathname is the longest prefix match against
* `pathname`, or null if none match. Used by the navigation bar to mark the
* active tab and by the masquerade redirect to decide whether the demoted
* user can still see the page they're on.
*/
export function findActiveTab(tabs: CourseTab[], pathname: string): CourseTab | null {
let best: { tab: CourseTab, length: number } | null = null;
for (const tab of tabs) {
const tabPathname = new URL(tab.url).pathname;
Comment thread
brian-smith-tcril marked this conversation as resolved.
const match = matchPath({ path: `${tabPathname}/*`, end: false }, pathname);
if (match && (!best || tabPathname.length > best.length)) {
best = { tab, length: tabPathname.length };
}
}
return best?.tab ?? null;
}
99 changes: 99 additions & 0 deletions shell/header/course-bar/masquerade/MasqueradeBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof api.getMasqueradeOptions>;

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(
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/course/:courseId/unit/:unitId" element={<MasqueradeBar />} />
<Route path="/course/:courseId" element={<MasqueradeBar />} />
</Routes>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>,
);
}

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();
});
});
51 changes: 51 additions & 0 deletions shell/header/course-bar/masquerade/MasqueradeBar.tsx
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
brian-smith-tcril marked this conversation as resolved.
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 (
<MasqueradeContext.Provider value={masquerade}>
<div role="region" aria-label={formatMessage(messages.ariaLabel)}>
<div className="bg-primary text-white">
<Container fluid size="xl">
<div className="py-3 d-md-flex justify-content-end align-items-start">
{!isUnreachable && (
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
<MasqueradeWidget />
</div>
)}
<StudioLink courseId={courseId} unitId={unitId} />
</div>
</Container>
</div>
{errorMessage && (
<Container fluid size="xl" className="mt-3">
<Alert variant="warning" role="alert" dismissible={false} className="mb-0">
{formatErrorMessage(formatMessage, errorMessage)}
</Alert>
</Container>
)}
</div>
</MasqueradeContext.Provider>
);
}
13 changes: 13 additions & 0 deletions shell/header/course-bar/masquerade/MasqueradeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';

import type { MasqueradeState } from './hooks';

export const MasqueradeContext = createContext<MasqueradeState | null>(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;
}
48 changes: 48 additions & 0 deletions shell/header/course-bar/masquerade/StudioLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<hr className="border-light" />
<span className="mr-2 mt-1 col-form-label">
<FormattedMessage {...messages.titleViewCourseIn} />
</span>
<span className="mx-1 my-1">
<Button variant="inverse-outline-primary" href={url}>
{formatMessage(messages.titleStudio)}
</Button>
</span>
</>
);
}

export default StudioLink;
45 changes: 45 additions & 0 deletions shell/header/course-bar/masquerade/data/api.ts
Original file line number Diff line number Diff line change
@@ -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<MasqueradeStatus> {
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<MasqueradeStatus> {
const url = `${getSiteConfig().lmsBaseUrl}/courses/${courseId}/masquerade`;
const { data } = await getAuthenticatedHttpClient().post(url, payload);
return camelCaseObject(data);
}
Loading
Loading