Skip to content

Commit 00e7b64

Browse files
feat: masquerade bar migrated to frontend base and react query
1 parent d350490 commit 00e7b64

14 files changed

Lines changed: 845 additions & 3 deletions

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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// TODO: UPDATE TESTS
2+
// import { getConfig } from '@edx/frontend-platform';
3+
// import MockAdapter from 'axios-mock-adapter';
4+
// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5+
// import {
6+
// initializeTestStore, render, screen, waitFor, getByText, logUnhandledRequests,
7+
// } from '../setupTest';
8+
// import InstructorToolbar from './index';
9+
10+
// const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
11+
// jest.mock('@edx/frontend-platform', () => ({
12+
// ...jest.requireActual('@edx/frontend-platform'),
13+
// getConfig: jest.fn(),
14+
// }));
15+
// getConfig.mockImplementation(() => originalConfig);
16+
17+
// describe('Instructor Toolbar', () => {
18+
// let courseware;
19+
// let models;
20+
// let mockData;
21+
// let axiosMock;
22+
// let masqueradeUrl;
23+
24+
// beforeAll(async () => {
25+
// const store = await initializeTestStore();
26+
// courseware = store.getState().courseware;
27+
// models = store.getState().models;
28+
29+
// axiosMock = new MockAdapter(getAuthenticatedHttpClient());
30+
// masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
31+
// });
32+
33+
// beforeEach(() => {
34+
// mockData = {
35+
// courseId: courseware.courseId,
36+
// unitId: Object.values(models.units)[0].id,
37+
// };
38+
// axiosMock.reset();
39+
// axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
40+
// logUnhandledRequests(axiosMock);
41+
// });
42+
43+
// it('sends query to masquerade and does not display alerts by default', async () => {
44+
// render(<InstructorToolbar {...mockData} />);
45+
46+
// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
47+
// expect(screen.queryByRole('alert')).not.toBeInTheDocument();
48+
// });
49+
50+
// it('displays masquerade error', async () => {
51+
// axiosMock.reset();
52+
// axiosMock.onGet(masqueradeUrl).reply(200, { success: false });
53+
// render(<InstructorToolbar {...mockData} />);
54+
55+
// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
56+
// await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options'));
57+
// });
58+
59+
// it('displays links to view course in available services', () => {
60+
// const config = { ...originalConfig };
61+
// config.INSIGHTS_BASE_URL = 'http://localhost:18100';
62+
// getConfig.mockImplementation(() => config);
63+
// render(<InstructorToolbar {...mockData} />);
64+
65+
// const linksContainer = screen.getByText('View course in:').parentElement;
66+
// ['Studio', 'Insights'].forEach(service => {
67+
// expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
68+
// });
69+
// });
70+
71+
// it('does not display links if there are no services available', () => {
72+
// const config = { ...originalConfig };
73+
// config.STUDIO_BASE_URL = undefined;
74+
// getConfig.mockImplementation(() => config);
75+
// render(<InstructorToolbar {...mockData} unitId={null} />);
76+
77+
// expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
78+
// });
79+
// });
Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,101 @@
1-
const MasqueradeBar = () => <div></div>;
1+
import React, { useEffect, useState } from 'react';
2+
import { useParams } from 'react-router-dom';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { getSiteConfig, useIntl, FormattedMessage, Slot } from '@openedx/frontend-base';
5+
import { Alert } from '@openedx/paragon';
6+
7+
import MasqueradeWidget from './masquerade-widget';
8+
import messages from './messages';
9+
10+
function getInsightsUrl(courseId?: string): string | undefined {
11+
const urlBase = (getSiteConfig() as any).INSIGHTS_BASE_URL;
12+
let urlFull: string | undefined;
13+
if (urlBase) {
14+
urlFull = `${urlBase}/courses`;
15+
if (courseId) {
16+
urlFull += `/${courseId}`;
17+
}
18+
}
19+
return urlFull;
20+
}
21+
22+
function getStudioUrl(courseId?: string, unitId?: string): string | undefined {
23+
const urlBase = (getSiteConfig() as any).STUDIO_BASE_URL;
24+
let urlFull: string | undefined;
25+
if (urlBase) {
26+
if (unitId) {
27+
urlFull = `${urlBase}/container/${unitId}`;
28+
} else if (courseId) {
29+
urlFull = `${urlBase}/course/${courseId}`;
30+
}
31+
}
32+
return urlFull;
33+
}
34+
35+
interface MasqueradeBarProps {
36+
isStudioButtonVisible?: boolean;
37+
}
38+
39+
const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
40+
isStudioButtonVisible = true,
41+
}) => {
42+
const { courseId = '', unitId = '' } = useParams();
43+
44+
const [didMount, setDidMount] = useState(false);
45+
// eslint-disable-next-line react-hooks/exhaustive-deps
46+
useEffect(() => {
47+
setDidMount(true);
48+
return () => setDidMount(false);
49+
});
50+
51+
const urlInsights = getInsightsUrl(courseId);
52+
const urlStudio = getStudioUrl(courseId, unitId);
53+
const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null);
54+
const [queryClient] = useState(() => new QueryClient({
55+
defaultOptions: {
56+
queries: { retry: false, refetchOnWindowFocus: false },
57+
},
58+
}));
59+
const { formatMessage } = useIntl();
60+
61+
return (!didMount ? null : (
62+
<QueryClientProvider client={queryClient}>
63+
<div data-testid="instructor-toolbar">
64+
<div className="bg-primary text-white">
65+
<div className="container-xl py-3 d-md-flex justify-content-end align-items-start">
66+
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
67+
<MasqueradeWidget courseId={courseId} onError={showMasqueradeError} />
68+
</div>
69+
{((urlStudio && isStudioButtonVisible) || urlInsights) && (
70+
<>
71+
<hr className="border-light" />
72+
<span className="mr-2 mt-1 col-form-label"><FormattedMessage {...messages.titleViewCourseIn} /></span>
73+
</>
74+
)}
75+
{urlStudio && isStudioButtonVisible && (
76+
<span className="mx-1 my-1">
77+
<a className="btn btn-inverse-outline-primary" href={urlStudio}>{formatMessage(messages.titleStudio)}</a>
78+
</span>
79+
)}
80+
{urlInsights && (
81+
<span className="mx-1 my-1">
82+
<a className="btn btn-inverse-outline-primary" href={urlInsights}>{formatMessage(messages.titleInsights)}</a>
83+
</span>
84+
)}
85+
</div>
86+
</div>
87+
{masqueradeErrorMessage && (
88+
<div className="container-xl mt-3">
89+
<Alert variant="danger" dismissible={false}>
90+
{masqueradeErrorMessage}
91+
</Alert>
92+
</div>
93+
)}
94+
// TODO: check this Slot
95+
{/* <Slot id="org.openedx.frontend.slot.header.masqueradeBar.alerts.v1" /> */}
96+
</div>
97+
</QueryClientProvider>
98+
));
99+
};
2100

3101
export default MasqueradeBar;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './MasqueradeBar';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
3+
import type {
4+
ActiveMasqueradeData, MasqueradeStatus, Payload, Role,
5+
} from './data/api';
6+
7+
export interface MasqueradeContextValue {
8+
active: ActiveMasqueradeData;
9+
onSubmit: (payload: Payload) => Promise<MasqueradeStatus>;
10+
onError: (error: string) => void;
11+
userNameInputToggle: (
12+
show: boolean | undefined,
13+
groupId: number | null,
14+
groupName: string,
15+
role: Role,
16+
userName: string,
17+
userPartitionId: number | null,
18+
) => void;
19+
}
20+
21+
export const MasqueradeContext = React.createContext<MasqueradeContextValue | null>(null);
22+
23+
export function useMasqueradeContext(): MasqueradeContextValue {
24+
const context = React.useContext(MasqueradeContext);
25+
if (context === null) {
26+
throw new Error('useMasqueradeContext must be used within a MasqueradeContext.Provider');
27+
}
28+
return context;
29+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from 'react';
2+
import { useIntl } from '@openedx/frontend-base';
3+
import { Form } from '@openedx/paragon';
4+
5+
import { useMasqueradeContext } from './MasqueradeContext';
6+
import { Payload } from './data/api';
7+
import messages from './messages';
8+
9+
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onSubmit' | 'onError'>;
10+
11+
export const MasqueradeUserNameInput: React.FC<Props> = ({ ...otherProps }) => {
12+
const { onSubmit, onError } = useMasqueradeContext();
13+
const intl = useIntl();
14+
15+
const handleSubmit = React.useCallback((userIdentifier: string) => {
16+
const payload: Payload = {
17+
role: 'student',
18+
user_name: userIdentifier, // user name or email
19+
};
20+
onSubmit(payload).then((data) => {
21+
if (data && data.success) {
22+
global.location.reload();
23+
} else {
24+
const error = (data && data.error) || '';
25+
onError(error);
26+
}
27+
}).catch(() => {
28+
const message = intl.formatMessage(messages.genericError);
29+
onError(message);
30+
});
31+
return true;
32+
}, [onSubmit, onError, intl]);
33+
34+
const handleKeyPress = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
35+
if (event.key === 'Enter') {
36+
return handleSubmit(event.currentTarget.value);
37+
}
38+
return true;
39+
}, [handleSubmit]);
40+
41+
return (
42+
<Form.Control
43+
aria-labelledby="masquerade-search-label"
44+
label={intl.formatMessage(messages.userNameLabel)}
45+
onKeyPress={handleKeyPress}
46+
{...otherProps}
47+
/>
48+
);
49+
};

0 commit comments

Comments
 (0)