feat: make maquerade stateful

feat: implement loading screen

chore: update selector test

chore: fiter/sort padding

chore: update fail masquerade state

chore: update unit test

chore: Update src/containers/MasqueradeBar/index.jsx

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>

chore: update test

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>

chore: update snapshot
This commit is contained in:
Leangseu Kim
2022-09-30 12:09:45 -04:00
committed by leangseu-edx
parent 7f210e7483
commit 84446fe5cd
19 changed files with 220 additions and 78 deletions

View File

@@ -92,7 +92,7 @@ export const CourseFilterControls = ({
<div className="filter-form-col">
<FilterForm {...{ filters, handleFilterChange }} />
</div>
<hr className="h-100 bg-primary-200 m-1" />
<hr className="h-100 bg-primary-200 mx-3 my-0" />
<div className="filter-form-col text-left m-1">
<SortForm {...{ sortBy, handleSortChange }} />
</div>

View File

@@ -98,7 +98,7 @@ exports[`CourseFilterControls snapshot is not mobile 1`] = `
/>
</div>
<hr
className="h-100 bg-primary-200 m-1"
className="h-100 bg-primary-200 mx-3 my-0"
/>
<div
className="filter-form-col text-left m-1"

View File

@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseList snapshots renders loading 1`] = `
<div
className="course-list-loading"
>
<Spinner
animation="border"
className="mie-3"
screenReaderText="loading"
/>
</div>
`;
exports[`CourseList snapshots with filters 1`] = `
<div
className="course-list-container"

View File

@@ -6,6 +6,7 @@ import { useCheckboxSetValues } from '@edx/paragon';
import { StrictDict } from 'utils';
import { actions, hooks as appHooks } from 'data/redux';
import { ListPageSize, SortKeys } from 'data/constants/app';
import { RequestKeys } from 'data/constants/requests';
import * as module from './hooks';
@@ -27,6 +28,8 @@ export const useCourseListData = () => {
});
const handleRemoveFilter = (filter) => () => setFilters.remove(filter);
const setPageNumber = (value) => dispatch(actions.app.setPageNumber(value));
const initIsPending = appHooks.useIsPendingRequest(RequestKeys.initialize);
return {
pageNumber,
numPages,
@@ -40,6 +43,7 @@ export const useCourseListData = () => {
handleRemoveFilter,
},
showFilters: filters.length > 0,
initIsPending,
};
};

View File

@@ -15,6 +15,7 @@ jest.mock('data/redux', () => ({
hooks: {
useCurrentCourseList: jest.fn(),
usePageNumber: jest.fn(() => 23),
useIsPendingRequest: jest.fn(),
},
}));
@@ -34,6 +35,11 @@ const testCheckboxSetValues = [testFilters, testSetFilters];
describe('CourseList hooks', () => {
let out;
appHooks.useCurrentCourseList.mockReturnValue(testListData);
appHooks.useIsPendingRequest.mockReturnValue(false);
paragon.useCheckboxSetValues.mockImplementation(() => testCheckboxSetValues);
describe('state values', () => {
state.testGetter(state.keys.sortBy);
jest.clearAllMocks();
@@ -44,8 +50,6 @@ describe('CourseList hooks', () => {
beforeEach(() => {
state.mock();
state.mockVal(state.keys.sortBy, testSortBy);
paragon.useCheckboxSetValues.mockImplementationOnce(() => testCheckboxSetValues);
appHooks.useCurrentCourseList.mockReturnValueOnce(testListData);
out = hooks.useCourseListData();
});
describe('behavior', () => {
@@ -72,12 +76,17 @@ describe('CourseList hooks', () => {
test('showFilters is true iff filters is not empty', () => {
expect(out.showFilters).toEqual(true);
state.mockVal(state.keys.sortBy, testSortBy);
appHooks.useCurrentCourseList.mockReturnValueOnce(testListData);
paragon.useCheckboxSetValues.mockReturnValueOnce([[], testSetFilters]);
out = hooks.useCourseListData();
// checkbox values default to returning a list, and were only overridden once.
// Thus this time, the values for the list should be empty.
// don't show filter when list is empty.
expect(out.showFilters).toEqual(false);
});
test('initIsPending loads from useIsPendingRequest', () => {
expect(out.initIsPending).toEqual(false);
appHooks.useIsPendingRequest.mockReturnValueOnce(true);
out = hooks.useCourseListData();
expect(out.initIsPending).toEqual(true);
});
describe('filterOptions', () => {
test('sortBy and setSortBy are connected to the state value', () => {
expect(out.filterOptions.sortBy).toEqual(testSortBy);

View File

@@ -1,9 +1,12 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Pagination } from '@edx/paragon';
import { Pagination, Spinner } from '@edx/paragon';
import { ActiveCourseFilters, CourseFilterControls } from 'containers/CourseFilterControls';
import {
ActiveCourseFilters,
CourseFilterControls,
} from 'containers/CourseFilterControls';
import CourseCard from 'containers/CourseCard';
import { useCourseListData } from './hooks';
@@ -20,21 +23,21 @@ export const CourseList = () => {
numPages,
showFilters,
visibleList,
initIsPending,
} = useCourseListData();
return (
return initIsPending ? (
<div className="course-list-loading">
<Spinner animation="border" className="mie-3" screenReaderText="loading" />
</div>
) : (
<div className="course-list-container">
<div id="course-list-heading-container">
<h2 className="my-3">
{formatMessage(messages.myCourses)}
</h2>
<div
id="course-filter-controls-container"
className="text-right"
>
<h2 className="my-3">{formatMessage(messages.myCourses)}</h2>
<div id="course-filter-controls-container" className="text-right">
<CourseFilterControls {...filterOptions} />
</div>
</div>
{ showFilters && (
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} />
</div>
@@ -43,7 +46,7 @@ export const CourseList = () => {
{visibleList.map(({ cardId }) => (
<CourseCard key={cardId} cardId={cardId} />
))}
{(numPages > 1) && (
{numPages > 1 && (
<Pagination
variant="secondary"
paginationLabel="Course List"
@@ -57,7 +60,6 @@ export const CourseList = () => {
);
};
CourseList.propTypes = {
};
CourseList.propTypes = {};
export default CourseList;

View File

@@ -2,3 +2,10 @@
display: flex;
justify-content: space-between;
}
.course-list-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}

View File

@@ -20,6 +20,7 @@ describe('CourseList', () => {
setPageNumber: jest.fn().mockName('setPageNumber'),
showFilters: false,
visibleList: [],
initIsPending: false,
};
const createWrapper = (courseListData) => {
useCourseListData.mockReturnValueOnce({
@@ -30,6 +31,10 @@ describe('CourseList', () => {
};
describe('snapshots', () => {
it('renders loading', () => {
const wrapper = createWrapper({ initIsPending: true });
expect(wrapper).toMatchSnapshot();
});
test('with no filters', () => {
const wrapper = createWrapper();
expect(wrapper).toMatchSnapshot();

View File

@@ -10,7 +10,7 @@ exports[`Dashboard snapshots there are available dashboards 1`] = `
</div>
`;
exports[`Dashboard snapshots there are courses 1`] = `
exports[`Dashboard snapshots there are courses, or they are still loading 1`] = `
<div
className="d-flex flex-column p-2"
id="dashboard-container"

View File

@@ -6,6 +6,7 @@ import {
thunkActions,
hooks as appHooks,
} from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import CourseList from 'containers/CourseList';
import WidgetSidebar from 'containers/WidgetSidebar';
@@ -25,10 +26,12 @@ export const Dashboard = () => {
const hasCourses = appHooks.useHasCourses();
const hasAvailableDashboards = appHooks.useHasAvailableDashboards();
const showSelectSessionModal = appHooks.useShowSelectSessionModal();
const initIsPending = appHooks.useIsPendingRequest(RequestKeys.initialize);
return (
<div id="dashboard-container" className="d-flex flex-column p-2">
{hasAvailableDashboards && <EnterpriseDashboardModal />}
{hasCourses ? (
{initIsPending || (!initIsPending && hasCourses) ? (
<Container fluid size="xl">
<Row>
<Col

View File

@@ -20,6 +20,7 @@ jest.mock('data/redux', () => ({
useHasCourses: jest.fn(),
useHasAvailableDashboards: jest.fn(),
useShowSelectSessionModal: jest.fn(),
useIsPendingRequest: jest.fn(),
},
}));
@@ -34,21 +35,32 @@ describe('Dashboard', () => {
hasCourses,
hasAvailableDashboards,
showSelectSessionModal,
initIsPending,
}) => {
hooks.useHasCourses.mockReturnValueOnce(hasCourses);
hooks.useHasAvailableDashboards.mockReturnValueOnce(hasAvailableDashboards);
hooks.useShowSelectSessionModal.mockReturnValueOnce(showSelectSessionModal);
hooks.useIsPendingRequest.mockReturnValueOnce(initIsPending);
return shallow(<Dashboard />);
};
describe('snapshots', () => {
test('there are courses', () => {
const wrapper = createWrapper({
test('there are courses, or they are still loading', () => {
const pendingNoCoursesWrapper = createWrapper({
hasCourses: false,
hasAvailableDashboards: false,
showSelectSessionModal: false,
initIsPending: true,
});
expect(pendingNoCoursesWrapper).toMatchSnapshot();
const doneLoadingWithCoursesWrapper = createWrapper({
hasCourses: true,
hasAvailableDashboards: false,
showSelectSessionModal: false,
initIsPending: false,
});
expect(wrapper).toMatchSnapshot();
expect(doneLoadingWithCoursesWrapper).toEqual(pendingNoCoursesWrapper);
});
test('there are no courses', () => {
@@ -56,6 +68,7 @@ describe('Dashboard', () => {
hasCourses: false,
hasAvailableDashboards: false,
showSelectSessionModal: false,
initIsPending: false,
});
expect(wrapper).toMatchSnapshot();
});
@@ -65,6 +78,7 @@ describe('Dashboard', () => {
hasCourses: false,
hasAvailableDashboards: true,
showSelectSessionModal: false,
initIsPending: false,
});
expect(wrapper).toMatchSnapshot();
});
@@ -74,6 +88,7 @@ describe('Dashboard', () => {
hasCourses: false,
hasAvailableDashboards: false,
showSelectSessionModal: true,
initIsPending: false,
});
expect(wrapper).toMatchSnapshot();
});
@@ -85,6 +100,7 @@ describe('Dashboard', () => {
hasCourses: false,
hasAvailableDashboards: false,
showSelectSessionModal: false,
initIsPending: false,
});
expect(wrapper.find(EmptyCourse).length).toEqual(1);
expect(wrapper.find(CourseList).length).toEqual(0);
@@ -97,6 +113,7 @@ describe('Dashboard', () => {
hasCourses: true,
hasAvailableDashboards: true,
showSelectSessionModal: true,
initIsPending: false,
});
expect(wrapper.find(EmptyCourse).length).toEqual(0);
expect(wrapper.find(CourseList).length).toEqual(1);
@@ -109,6 +126,7 @@ describe('Dashboard', () => {
hasCourses: true,
hasAvailableDashboards: true,
showSelectSessionModal: false,
initIsPending: false,
});
expect(wrapper.find(EmptyCourse).length).toEqual(0);
expect(wrapper.find(CourseList).length).toEqual(1);

View File

@@ -20,12 +20,16 @@ exports[`MasqueradeBar snapshot can masquerade 1`] = `
value=""
/>
</FormGroup>
<Button
<StatefulButton
disabled={true}
variant="danger"
>
Submit
</Button>
labels={
Object {
"default": "Submit",
}
}
state="default"
variant="brand"
/>
</div>
`;
@@ -49,12 +53,16 @@ exports[`MasqueradeBar snapshot can masquerade with input 1`] = `
value="test"
/>
</FormGroup>
<Button
<StatefulButton
disabled={false}
variant="danger"
>
Submit
</Button>
labels={
Object {
"default": "Submit",
}
}
state="default"
variant="brand"
/>
</div>
`;
@@ -80,17 +88,55 @@ exports[`MasqueradeBar snapshot is masquerading failed with error 1`] = `
value=""
/>
<FormControlFeedback
hasIcon={false}
type="invalid"
>
test-error
</FormControlFeedback>
</FormGroup>
<Button
<StatefulButton
disabled={true}
variant="danger"
labels={
Object {
"default": "Submit",
}
}
state="default"
variant="brand"
/>
</div>
`;
exports[`MasqueradeBar snapshot is masquerading pending 1`] = `
<div
className="masquerade-bar"
>
<FormLabel
className="masquerade-form-label"
inline={true}
>
Submit
</Button>
View as:
</FormLabel>
<FormGroup
className="masquerade-form-input"
isInvalid={false}
>
<FormControl
floatingLabel="Student username or email"
onChange={[MockFunction handleMasqueradeInputChange]}
value=""
/>
</FormGroup>
<StatefulButton
disabled={true}
labels={
Object {
"default": "Submit",
}
}
state="pending"
variant="brand"
/>
</div>
`;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { thunkActions, selectors } from 'data/redux';
import { thunkActions, hooks as appHooks } from 'data/redux';
import { StrictDict } from 'utils';
import * as module from './hooks';
@@ -32,14 +32,16 @@ export const useMasqueradeBarData = ({
const {
isMasquerading,
isMasqueradingFailed,
isMasqueradingPending,
masqueradeError,
} = useSelector(selectors.requests.masquerade);
} = appHooks.useMasqueradeData();
const { masqueradeInput, handleMasqueradeInputChange } = module.useMasqueradeInput();
return {
canMasquerade,
isMasquerading,
isMasqueradingFailed,
isMasqueradingPending,
masqueradeError,
masqueradeInput,
handleMasqueradeSubmit,

View File

@@ -1,7 +1,6 @@
import { MockUseState } from 'testUtils';
import { thunkActions } from 'data/redux';
import { thunkActions, hooks as appHooks } from 'data/redux';
import { useSelector } from 'react-redux';
import * as hooks from './hooks';
jest.mock('data/redux', () => ({
@@ -11,58 +10,70 @@ jest.mock('data/redux', () => ({
clearMasquerade: jest.fn(),
},
},
selectors: {
requests: {
masquerade: jest.fn(),
},
hooks: {
useMasqueradeData: jest.fn(),
},
}));
const state = new MockUseState(hooks);
describe('MasqueradeBar hooks', () => {
let out;
const authenticatedUser = {
administrator: true,
};
const defaultMasqueradeData = {
isMasquerading: false,
isMasqueradingFailed: false,
isMasqueradingPending: false,
masqueradeError: null,
};
const createHook = (masqueradeData = {}, user) => {
appHooks.useMasqueradeData.mockReturnValueOnce({
...defaultMasqueradeData,
...masqueradeData,
});
return hooks.useMasqueradeBarData({ authenticatedUser: user || authenticatedUser });
};
describe('state values', () => {
state.testGetter(state.keys.masqueradeInput);
});
describe('useMasqueradeBarData', () => {
beforeEach(() => {
state.mock();
out = hooks.useMasqueradeBarData({ authenticatedUser });
});
beforeEach(() => state.mock());
afterEach(state.restore);
test('canMasquerade', () => {
const out = createHook();
expect(out.canMasquerade).toEqual(true);
});
test('cannotMasquerade', () => {
out = hooks.useMasqueradeBarData({
authenticatedUser: {
administrator: false,
},
});
const out = createHook({}, { administrator: false });
expect(out.canMasquerade).toEqual(false);
});
test('masqueradeError', () => {
expect(out.masqueradeError).toBeUndefined();
useSelector.mockReturnValueOnce({
masqueradeError: 'test error',
});
out = hooks.useMasqueradeBarData({ authenticatedUser });
let out = createHook();
expect(out.masqueradeError).toBeNull();
out = createHook({ masqueradeError: 'test error' });
expect(out.masqueradeError).toEqual('test error');
});
test('isMasqueradePending', () => {
let out = createHook();
expect(out.isMasqueradingPending).toEqual(false);
out = createHook({ isMasqueradingPending: true });
expect(out.isMasqueradingPending).toEqual(true);
});
test('handleMasqueradeInputChange', () => {
const out = createHook();
expect(state.stateVals.masqueradeInput).toEqual('');
out.handleMasqueradeInputChange({ target: { value: 'test' } });
expect(state.setState.masqueradeInput).toHaveBeenCalledWith('test');
});
test('handleMasqueradeSubmit', () => {
const out = createHook();
out.handleMasqueradeSubmit('test')();
expect(thunkActions.app.masqueradeAs).toHaveBeenCalledWith('test');
});
test('handleClearMasquerade', () => {
const out = createHook();
out.handleClearMasquerade();
expect(thunkActions.app.clearMasquerade).toHaveBeenCalled();
});

View File

@@ -3,11 +3,11 @@ import { AppContext } from '@edx/frontend-platform/react';
import {
Chip,
Button,
FormControl,
FormControlFeedback,
FormLabel,
FormGroup,
StatefulButton,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
@@ -22,6 +22,7 @@ export const MasqueradeBar = () => {
canMasquerade,
isMasquerading,
isMasqueradingFailed,
isMasqueradingPending,
masqueradeInput,
masqueradeError,
handleMasqueradeInputChange,
@@ -59,18 +60,20 @@ export const MasqueradeBar = () => {
floatingLabel={formatMessage(messages.StudentNameInput)}
/>
{isMasqueradingFailed && (
<FormControlFeedback type="invalid">
<FormControlFeedback type="invalid" hasIcon={false}>
{masqueradeError}
</FormControlFeedback>
)}
</FormGroup>
<Button
<StatefulButton
disabled={!masqueradeInput.length}
variant="danger"
variant="brand"
onClick={handleMasqueradeSubmit(masqueradeInput)}
>
{formatMessage(messages.SubmitButton)}
</Button>
labels={{
default: formatMessage(messages.SubmitButton),
}}
state={isMasqueradingPending ? 'pending' : 'default'}
/>
</>
)}
</div>

View File

@@ -14,6 +14,7 @@ describe('MasqueradeBar', () => {
canMasquerade: true,
isMasquerading: false,
isMasqueradingFailed: false,
isMasqueradingPending: false,
masqueradeInput: '',
masqueradeError: '',
handleMasqueradeInputChange: jest.fn().mockName('handleMasqueradeInputChange'),
@@ -57,5 +58,12 @@ describe('MasqueradeBar', () => {
});
expect(shallow(<MasqueradeBar />)).toMatchSnapshot();
});
test('is masquerading pending', () => {
hooks.useMasqueradeBarData.mockReturnValueOnce({
...masqueradeMockData,
isMasqueradingPending: true,
});
expect(shallow(<MasqueradeBar />)).toMatchSnapshot();
});
});
});

View File

@@ -40,3 +40,5 @@ export const useUpdateSelectSessionModalCallback = (dispatch, cardId) => () => d
);
export const useMasqueradeData = () => useSelector(requestSelectors.masquerade);
export const useIsPendingRequest = (requestName) => useSelector(requestSelectors.isPending(requestName));

View File

@@ -4,7 +4,7 @@ import { RequestStates, RequestKeys } from 'data/constants/requests';
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
const statusSelector = (fn) => (requestKey) => (state) => fn(state.requests[requestKey]);
export const isInactive = ({ status }) => status === RequestStates.inactive;
export const isPending = ({ status }) => status === RequestStates.pending;
@@ -21,6 +21,7 @@ export const masquerade = (state) => {
return {
isMasquerading: isCompleted(request),
isMasqueradingFailed: isFailed(request),
isMasqueradingPending: isPending(request),
masqueradeError: error(request),
};
};

View File

@@ -20,22 +20,23 @@ const testState = {
[requestKey]: requestData,
},
};
const mockUseSelector = (selector, state) => selector(state);
const genRequests = (request) => ({
requests: { [requestKey]: request },
});
const select = (selector, request) => (
selector(genRequests(request), { requestKey })
mockUseSelector(
selector(requestKey), genRequests(request),
)
);
describe('requests selectors unit tests', () => {
test('requestStatus returns data associated with given key', () => {
expect(selectors.requestStatus(testState, { requestKey })).toEqual(requestData);
});
const testStatusSelector = (selector, matchingRequest) => {
expect(selector(testState, { requestKey })).toEqual(false);
expect(selector(
{ requests: { [requestKey]: matchingRequest } },
{ requestKey },
)).toEqual(true);
expect(mockUseSelector(selector(requestKey), testState)).toEqual(false);
expect(mockUseSelector(selector(requestKey),
{ requests: { [requestKey]: matchingRequest } })).toEqual(true);
};
test('isInactive returns true iff the given request is inactive', () => {
testStatusSelector(selectors.isInactive, inactiveRequest);
@@ -78,6 +79,13 @@ describe('requests selectors unit tests', () => {
expect(selectors.masquerade(mockResponse(completedRequest))).toEqual({
isMasquerading: true,
isMasqueradingFailed: false,
isMasqueradingPending: false,
masqueradeError: undefined,
});
expect(selectors.masquerade(mockResponse(pendingRequest))).toEqual({
isMasquerading: false,
isMasqueradingFailed: false,
isMasqueradingPending: true,
masqueradeError: undefined,
});
expect(selectors.masquerade(mockResponse({
@@ -86,6 +94,7 @@ describe('requests selectors unit tests', () => {
}))).toEqual({
isMasquerading: false,
isMasqueradingFailed: true,
isMasqueradingPending: false,
masqueradeError: testValue,
});
});