-
Notifications
You must be signed in to change notification settings - Fork 8
feat: masquerade bar #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: masquerade bar #260
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" /> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| <Slot id="org.openedx.frontend.slot.header.courseNavigationBar.v1" /> | ||
| </> | ||
| ); | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| 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(); | ||
|
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> | ||
| ); | ||
| } | ||
| 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; | ||
| } |
| 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; |
| 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); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.