{
);
};
GreetingBanner.propTypes = {
- size: PropTypes.oneOf('small', 'large').isRequired,
+ size: PropTypes.oneOf(['small', 'large']).isRequired,
};
export default GreetingBanner;
diff --git a/src/containers/LearnerDashboardHeader/index.jsx b/src/containers/LearnerDashboardHeader/index.jsx
index 8b8ff22..096125d 100644
--- a/src/containers/LearnerDashboardHeader/index.jsx
+++ b/src/containers/LearnerDashboardHeader/index.jsx
@@ -5,6 +5,7 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Program } from '@edx/paragon/icons';
import { Button } from '@edx/paragon';
+import MasqueradeBar from 'containers/MasqueradeBar';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import GreetingBanner from './GreetingBanner';
@@ -43,6 +44,8 @@ export const LearnerDashboardHeader = () => {
{!isCollapsed && }
+
+
>
);
};
diff --git a/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap b/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..6434cb7
--- /dev/null
+++ b/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,115 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MasqueradeBar snapshot can masquerade 1`] = `
+
+
+ View as:
+
+
+
+
+
+ Submit
+
+
+`;
+
+exports[`MasqueradeBar snapshot can masquerade with input 1`] = `
+
+
+ View as:
+
+
+
+
+
+ Submit
+
+
+`;
+
+exports[`MasqueradeBar snapshot cannot masquerade 1`] = `""`;
+
+exports[`MasqueradeBar snapshot is masquerading failed with error 1`] = `
+
+
+ View as:
+
+
+
+
+ test-error
+
+
+
+ Submit
+
+
+`;
+
+exports[`MasqueradeBar snapshot is masquerading with input 1`] = `
+
+
+ Viewing as:
+
+
+ test
+
+
+`;
diff --git a/src/containers/MasqueradeBar/hooks.js b/src/containers/MasqueradeBar/hooks.js
new file mode 100644
index 0000000..54196cf
--- /dev/null
+++ b/src/containers/MasqueradeBar/hooks.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { thunkActions, selectors } from 'data/redux';
+import { StrictDict } from 'utils';
+import * as module from './hooks';
+
+export const state = StrictDict({
+ masqueradeInput: (val) => React.useState(val), // eslint-disable-line
+});
+
+export const useMasqueradeInput = () => {
+ const [masqueradeInput, setMasqueradeInput] = module.state.masqueradeInput('');
+ const handleMasqueradeInputChange = (e) => setMasqueradeInput(e.target.value);
+ return {
+ handleMasqueradeInputChange,
+ masqueradeInput,
+ };
+};
+
+export const useMasqueradeBarData = ({
+ authenticatedUser,
+}) => {
+ const dispatch = useDispatch();
+ const { formatMessage } = useIntl();
+ const canMasquerade = authenticatedUser?.administrator;
+
+ const handleMasqueradeSubmit = (user) => () => dispatch(thunkActions.app.masqueradeAs(user));
+ const handleClearMasquerade = () => dispatch(thunkActions.app.clearMasquerade());
+
+ const {
+ isMasquerading,
+ isMasqueradingFailed,
+ masqueradeError,
+ } = useSelector(selectors.requests.masquerade);
+ const { masqueradeInput, handleMasqueradeInputChange } = module.useMasqueradeInput();
+
+ return {
+ canMasquerade,
+ isMasquerading,
+ isMasqueradingFailed,
+ masqueradeError,
+ masqueradeInput,
+ handleMasqueradeSubmit,
+ handleClearMasquerade,
+ handleMasqueradeInputChange,
+ formatMessage,
+ };
+};
+
+export default useMasqueradeBarData;
diff --git a/src/containers/MasqueradeBar/hooks.test.js b/src/containers/MasqueradeBar/hooks.test.js
new file mode 100644
index 0000000..ab0773a
--- /dev/null
+++ b/src/containers/MasqueradeBar/hooks.test.js
@@ -0,0 +1,70 @@
+import { MockUseState } from 'testUtils';
+import { thunkActions } from 'data/redux';
+
+import { useSelector } from 'react-redux';
+import * as hooks from './hooks';
+
+jest.mock('data/redux', () => ({
+ thunkActions: {
+ app: {
+ masqueradeAs: jest.fn(),
+ clearMasquerade: jest.fn(),
+ },
+ },
+ selectors: {
+ requests: {
+ masquerade: jest.fn(),
+ },
+ },
+}));
+
+const state = new MockUseState(hooks);
+
+describe('MasqueradeBar hooks', () => {
+ let out;
+ const authenticatedUser = {
+ administrator: true,
+ };
+ describe('state values', () => {
+ state.testGetter(state.keys.masqueradeInput);
+ });
+ describe('useMasqueradeBarData', () => {
+ beforeEach(() => {
+ state.mock();
+ out = hooks.useMasqueradeBarData({ authenticatedUser });
+ });
+ afterEach(state.restore);
+ test('canMasquerade', () => {
+ expect(out.canMasquerade).toEqual(true);
+ });
+ test('cannotMasquerade', () => {
+ out = hooks.useMasqueradeBarData({
+ authenticatedUser: {
+ administrator: false,
+ },
+ });
+ expect(out.canMasquerade).toEqual(false);
+ });
+ test('masqueradeError', () => {
+ expect(out.masqueradeError).toBeUndefined();
+ useSelector.mockReturnValueOnce({
+ masqueradeError: 'test error',
+ });
+ out = hooks.useMasqueradeBarData({ authenticatedUser });
+ expect(out.masqueradeError).toEqual('test error');
+ });
+ test('handleMasqueradeInputChange', () => {
+ expect(state.stateVals.masqueradeInput).toEqual('');
+ out.handleMasqueradeInputChange({ target: { value: 'test' } });
+ expect(state.setState.masqueradeInput).toHaveBeenCalledWith('test');
+ });
+ test('handleMasqueradeSubmit', () => {
+ out.handleMasqueradeSubmit('test')();
+ expect(thunkActions.app.masqueradeAs).toHaveBeenCalledWith('test');
+ });
+ test('handleClearMasquerade', () => {
+ out.handleClearMasquerade();
+ expect(thunkActions.app.clearMasquerade).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/containers/MasqueradeBar/index.jsx b/src/containers/MasqueradeBar/index.jsx
new file mode 100644
index 0000000..20eb2ab
--- /dev/null
+++ b/src/containers/MasqueradeBar/index.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { AppContext } from '@edx/frontend-platform/react';
+
+import {
+ Chip,
+ Button,
+ FormControl,
+ FormControlFeedback,
+ FormLabel,
+ FormGroup,
+} from '@edx/paragon';
+import { Close } from '@edx/paragon/icons';
+
+import messages from './messages';
+import { useMasqueradeBarData } from './hooks';
+import './index.scss';
+
+export const MasqueradeBar = () => {
+ const { authenticatedUser } = React.useContext(AppContext);
+
+ const {
+ canMasquerade,
+ isMasquerading,
+ isMasqueradingFailed,
+ masqueradeInput,
+ masqueradeError,
+ handleMasqueradeInputChange,
+ handleClearMasquerade,
+ handleMasqueradeSubmit,
+ formatMessage,
+ } = useMasqueradeBarData({ authenticatedUser });
+
+ if (!canMasquerade) { return null; }
+
+ return (
+
+ {isMasquerading ? (
+ <>
+
+ {formatMessage(messages.ViewingAs)}
+
+
+ {masqueradeInput}
+
+ >
+ ) : (
+ <>
+
+ {formatMessage(messages.ViewAs)}
+
+
+
+ {isMasqueradingFailed && (
+
+ {masqueradeError}
+
+ )}
+
+
+ {formatMessage(messages.SubmitButton)}
+
+ >
+ )}
+
+ );
+};
+
+export default MasqueradeBar;
diff --git a/src/containers/MasqueradeBar/index.scss b/src/containers/MasqueradeBar/index.scss
new file mode 100644
index 0000000..7a9014b
--- /dev/null
+++ b/src/containers/MasqueradeBar/index.scss
@@ -0,0 +1,22 @@
+@import "@edx/paragon/scss/core/core";
+
+.masquerade-bar {
+ display: flex;
+ align-items: flex-start;
+ padding: map-get($spacers, 4) map-get($spacers, 3) 0 map-get($spacers, 3);
+
+ .masquerade-form-label {
+ padding: map-get($spacers, 2) map-get($spacers, 1);
+ }
+
+ .masquerade-form-input {
+ margin-bottom: 0;
+ flex-grow: 1;
+ max-width: map-get($grid-breakpoints, 'md');
+ }
+
+ .masquerade-chip {
+ padding: map-get($spacers, 2) map-get($spacers, 3);
+ font-size: $font-size-base;
+ }
+}
\ No newline at end of file
diff --git a/src/containers/MasqueradeBar/index.test.jsx b/src/containers/MasqueradeBar/index.test.jsx
new file mode 100644
index 0000000..f3704e7
--- /dev/null
+++ b/src/containers/MasqueradeBar/index.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { formatMessage } from 'testUtils';
+
+import MasqueradeBar from '.';
+import hooks from './hooks';
+
+jest.mock('./hooks', () => ({
+ useMasqueradeBarData: jest.fn(),
+}));
+
+describe('MasqueradeBar', () => {
+ const masqueradeMockData = {
+ canMasquerade: true,
+ isMasquerading: false,
+ isMasqueradingFailed: false,
+ masqueradeInput: '',
+ masqueradeError: '',
+ handleMasqueradeInputChange: jest.fn().mockName('handleMasqueradeInputChange'),
+ handleClearMasquerade: jest.fn().mockName('handleClearMasquerade'),
+ handleMasqueradeSubmit: jest.fn().mockName('handleMasqueradeSubmit'),
+ formatMessage,
+ };
+
+ describe('snapshot', () => {
+ test('can masquerade', () => {
+ hooks.useMasqueradeBarData.mockReturnValueOnce(masqueradeMockData);
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('can masquerade with input', () => {
+ hooks.useMasqueradeBarData.mockReturnValueOnce({
+ ...masqueradeMockData,
+ masqueradeInput: 'test',
+ });
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('cannot masquerade', () => {
+ hooks.useMasqueradeBarData.mockReturnValueOnce({
+ ...masqueradeMockData,
+ canMasquerade: false,
+ });
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('is masquerading with input', () => {
+ hooks.useMasqueradeBarData.mockReturnValueOnce({
+ ...masqueradeMockData,
+ isMasquerading: true,
+ masqueradeInput: 'test',
+ });
+ expect(shallow( )).toMatchSnapshot();
+ });
+ test('is masquerading failed with error', () => {
+ hooks.useMasqueradeBarData.mockReturnValueOnce({
+ ...masqueradeMockData,
+ isMasqueradingFailed: true,
+ masqueradeError: 'test-error',
+ });
+ expect(shallow( )).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/containers/MasqueradeBar/messages.js b/src/containers/MasqueradeBar/messages.js
new file mode 100644
index 0000000..fe9bfe7
--- /dev/null
+++ b/src/containers/MasqueradeBar/messages.js
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ ViewAs: {
+ id: 'MasqueradeBar.ViewAs',
+ defaultMessage: 'View as: ',
+ description: 'Label for the View as',
+ },
+ ViewingAs: {
+ id: 'MasqueradeBar.ViewingAs',
+ defaultMessage: 'Viewing as: ',
+ description: 'Label for the Viewing as',
+ },
+ SubmitButton: {
+ id: 'MasqueradeBar.SubmitButton',
+ defaultMessage: 'Submit',
+ description: 'Label for the Submit button',
+ },
+ StudentNameInput: {
+ id: 'MasqueradeBar.StudentNameInput',
+ defaultMessage: 'Student username or email',
+ description: 'Label for the Student Name or email input',
+ },
+});
+
+export default messages;
diff --git a/src/containers/RelatedProgramsModal/components/__snapshots__/ProgramCard.test.jsx.snap b/src/containers/RelatedProgramsModal/components/__snapshots__/ProgramCard.test.jsx.snap
index 53c3fcf..6a98133 100644
--- a/src/containers/RelatedProgramsModal/components/__snapshots__/ProgramCard.test.jsx.snap
+++ b/src/containers/RelatedProgramsModal/components/__snapshots__/ProgramCard.test.jsx.snap
@@ -42,6 +42,7 @@ exports[`RelatedProgramsModal ProgramCard snapshot 1`] = `
>
props.data.programType
diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js
index d02ba56..53a30c7 100644
--- a/src/data/constants/requests.js
+++ b/src/data/constants/requests.js
@@ -15,6 +15,9 @@ export const RequestKeys = StrictDict({
switchEntitlementSession: 'switchEntitlementSession',
unenrollFromCourse: 'unenrollFromCourse',
updateEmailSettings: 'updateEmailSettings',
+ enrollEntitlementSession: 'enrollEntitlementSession',
+ leaveEntitlementSession: 'leaveEntitlementSession',
+ masquerade: 'masquerade',
});
export const ErrorCodes = StrictDict({
diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js
index 7172d80..70ff3ea 100644
--- a/src/data/redux/hooks.js
+++ b/src/data/redux/hooks.js
@@ -2,6 +2,7 @@ import { useSelector } from 'react-redux';
import { actions as appActions } from './app/reducer';
import appSelectors from './app/selectors';
+import requestSelectors from './requests/selectors';
const { courseCard } = appSelectors;
@@ -36,3 +37,5 @@ export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPr
export const useUpdateSelectSessionModalCallback = (dispatch, cardId) => () => dispatch(
appActions.updateSelectSessionModal(cardId),
);
+
+export const useMasqueradeData = () => useSelector(requestSelectors.masquerade);
diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js
index b9dea91..58ac64c 100644
--- a/src/data/redux/requests/reducer.js
+++ b/src/data/redux/requests/reducer.js
@@ -6,6 +6,10 @@ import { RequestStates, RequestKeys } from 'data/constants/requests';
const initialState = {
[RequestKeys.initialize]: { status: RequestStates.inactive },
+ [RequestKeys.refreshList]: { status: RequestStates.inactive },
+ [RequestKeys.enrollEntitlementSession]: { status: RequestStates.inactive },
+ [RequestKeys.leaveEntitlementSession]: { status: RequestStates.inactive },
+ [RequestKeys.masquerade]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars
diff --git a/src/data/redux/requests/selectors.js b/src/data/redux/requests/selectors.js
index 52c983a..4c6278a 100644
--- a/src/data/redux/requests/selectors.js
+++ b/src/data/redux/requests/selectors.js
@@ -1,5 +1,5 @@
import { StrictDict } from 'utils';
-import { RequestStates } from 'data/constants/requests';
+import { RequestStates, RequestKeys } from 'data/constants/requests';
// import * as module from './selectors';
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
@@ -16,6 +16,15 @@ export const errorCode = (request) => request.error?.response?.data;
export const data = (request) => request.data;
+export const masquerade = (state) => {
+ const request = requestStatus(state, { requestKey: RequestKeys.masquerade });
+ return {
+ isMasquerading: isCompleted(request),
+ isMasqueradingFailed: isFailed(request),
+ masqueradeError: error(request),
+ };
+};
+
export default StrictDict({
requestStatus,
isInactive: statusSelector(isInactive),
@@ -26,4 +35,5 @@ export default StrictDict({
errorCode: statusSelector(errorCode),
errorStatus: statusSelector(errorStatus),
data: statusSelector(data),
+ masquerade,
});
diff --git a/src/data/redux/requests/selectors.test.js b/src/data/redux/requests/selectors.test.js
index 089dc71..9ee715f 100644
--- a/src/data/redux/requests/selectors.test.js
+++ b/src/data/redux/requests/selectors.test.js
@@ -1,4 +1,4 @@
-import { RequestStates } from 'data/constants/requests';
+import { RequestStates, RequestKeys } from 'data/constants/requests';
import selectors from './selectors';
@@ -69,4 +69,24 @@ describe('requests selectors unit tests', () => {
test('data reurns the request data', () => {
expect(select(selectors.data, { data: testValue })).toEqual(testValue);
});
+ test('masquerade returns the masquerade data', () => {
+ const mockResponse = (response) => ({
+ requests: {
+ [RequestKeys.masquerade]: response,
+ },
+ });
+ expect(selectors.masquerade(mockResponse(completedRequest))).toEqual({
+ isMasquerading: true,
+ isMasqueradingFailed: false,
+ masqueradeError: undefined,
+ });
+ expect(selectors.masquerade(mockResponse({
+ ...failedRequest,
+ error: testValue,
+ }))).toEqual({
+ isMasquerading: false,
+ isMasqueradingFailed: true,
+ masqueradeError: testValue,
+ });
+ });
});
diff --git a/src/data/redux/thunkActions/app.js b/src/data/redux/thunkActions/app.js
index b916025..5655239 100644
--- a/src/data/redux/thunkActions/app.js
+++ b/src/data/redux/thunkActions/app.js
@@ -80,6 +80,20 @@ export const unenrollFromCourse = (courseId, reason) => (dispatch) => {
}));
};
+export const masqueradeAs = (user) => (dispatch) => (
+ dispatch(requests.masqueradeAs({
+ user,
+ onSuccess: (({ courses }) => {
+ dispatch(actions.app.loadCourses({ courses }));
+ }),
+ }))
+);
+
+export const clearMasquerade = () => (dispatch) => {
+ dispatch(requests.clearMasquerade());
+ dispatch(module.refreshList());
+};
+
export default StrictDict({
initialize,
refreshList,
@@ -88,4 +102,6 @@ export default StrictDict({
switchEntitlementEnrollment,
leaveEntitlementSession,
unenrollFromCourse,
+ masqueradeAs,
+ clearMasquerade,
});
diff --git a/src/data/redux/thunkActions/requests.js b/src/data/redux/thunkActions/requests.js
index 92ba438..ecb4a81 100644
--- a/src/data/redux/thunkActions/requests.js
+++ b/src/data/redux/thunkActions/requests.js
@@ -84,8 +84,23 @@ export const updateEmailSettings = ({ courseId, enable, ...options }) => module.
options,
);
+export const masqueradeAs = ({ user, onSuccess, onFailure }) => (dispatch) => {
+ dispatch(networkRequest({
+ requestKey: RequestKeys.masquerade,
+ onFailure,
+ onSuccess,
+ promise: api.initializeList({ user }),
+ }));
+};
+
+export const clearMasquerade = () => (dispatch) => dispatch(
+ actions.requests.clearRequest({ requestKey: RequestKeys.masquerade }),
+);
+
export default StrictDict({
initializeList,
+ masqueradeAs,
+ clearMasquerade,
leaveEntitlementSession,
newEntitlementEnrollment,
switchEntitlementEnrollment,
diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js
index a21b386..65bb4bc 100644
--- a/src/data/services/lms/api.js
+++ b/src/data/services/lms/api.js
@@ -9,7 +9,11 @@ import urls from './urls';
/*********************************************************************************
* GET Actions
*********************************************************************************/
-const initializeList = () => get(urls.init).then(({ data }) => data);
+const initializeList = ({ user } = {}) => new Promise((resolve, reject) => {
+ get(stringifyUrl(urls.init, { user }))
+ .then(({ data }) => resolve(data))
+ .catch(({ response }) => reject(response ? response.statusText : 'Unknown Error'));
+});
const updateEntitlementEnrollment = ({ uuid, courseId }) => post(stringifyUrl(
urls.entitlementEnrollment(uuid),
diff --git a/src/setupTest.jsx b/src/setupTest.jsx
index 8db2aca..f426f93 100755
--- a/src/setupTest.jsx
+++ b/src/setupTest.jsx
@@ -52,6 +52,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Section: 'Card.Section',
},
CardGrid: 'CardGrid',
+ Chip: 'Chip',
Col: 'Col',
Collapsible: {
Advanced: 'Collapsible.Advanced',
@@ -82,7 +83,10 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
RadioSet: 'Form.RadioSet',
Switch: 'Form.Switch',
},
+ FormControl: 'FormControl',
FormControlFeedback: 'FormControlFeedback',
+ FormGroup: 'FormGroup',
+ FormLabel: 'FormLabel',
FullscreenModal: 'FullscreenModal',
Hyperlink: 'Hyperlink',
Icon: 'Icon',
@@ -118,11 +122,18 @@ jest.mock('@edx/paragon/icons', () => ({
ArrowDropDown: jest.fn().mockName('icons.ArrowDropDown'),
ArrowDropUp: jest.fn().mockName('icons.ArrowDropUp'),
Cancel: jest.fn().mockName('icons.Cancel'),
+ Close: jest.fn().mockName('icons.Close'),
+ CheckCircle: jest.fn().mockName('icons.CheckCircle'),
ChevronLeft: jest.fn().mockName('icons.ChevronLeft'),
ChevronRight: jest.fn().mockName('icons.ChevronRight'),
Highlight: jest.fn().mockName('icons.Highlight'),
+ Info: jest.fn().mockName('icons.Info'),
InfoOutline: jest.fn().mockName('icons.InfoOutline'),
Launch: jest.fn().mockName('icons.Launch'),
+ Locked: jest.fn().mockName('icons.Locked'),
+ MoreVert: jest.fn().mockName('icons.MoreVert'),
+ Tune: jest.fn().mockName('icons.Tune'),
+ Program: jest.fn().mockName('icons.Program'),
}));
jest.mock('data/constants/app', () => ({