diff --git a/src/containers/CourseFilterControls/CourseFilterControls.jsx b/src/containers/CourseFilterControls/CourseFilterControls.jsx index 59d00cd..e145b32 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -92,7 +92,7 @@ export const CourseFilterControls = ({
-
+
diff --git a/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap b/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap index 2bbd31b..3630c56 100644 --- a/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap +++ b/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap @@ -98,7 +98,7 @@ exports[`CourseFilterControls snapshot is not mobile 1`] = ` />
+ +
+`; + exports[`CourseList snapshots with filters 1`] = `
{ }); 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, }; }; diff --git a/src/containers/CourseList/hooks.test.js b/src/containers/CourseList/hooks.test.js index dfc9c22..9ff166c 100644 --- a/src/containers/CourseList/hooks.test.js +++ b/src/containers/CourseList/hooks.test.js @@ -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); diff --git a/src/containers/CourseList/index.jsx b/src/containers/CourseList/index.jsx index d57aae9..88ff7a1 100644 --- a/src/containers/CourseList/index.jsx +++ b/src/containers/CourseList/index.jsx @@ -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 ? ( +
+ +
+ ) : (
-

- {formatMessage(messages.myCourses)} -

-
+

{formatMessage(messages.myCourses)}

+
- { showFilters && ( + {showFilters && (
@@ -43,7 +46,7 @@ export const CourseList = () => { {visibleList.map(({ cardId }) => ( ))} - {(numPages > 1) && ( + {numPages > 1 && ( { ); }; -CourseList.propTypes = { -}; +CourseList.propTypes = {}; export default CourseList; diff --git a/src/containers/CourseList/index.scss b/src/containers/CourseList/index.scss index 6dd1e29..eeeeec1 100644 --- a/src/containers/CourseList/index.scss +++ b/src/containers/CourseList/index.scss @@ -2,3 +2,10 @@ display: flex; justify-content: space-between; } + +.course-list-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} \ No newline at end of file diff --git a/src/containers/CourseList/index.test.jsx b/src/containers/CourseList/index.test.jsx index 691a552..b848583 100644 --- a/src/containers/CourseList/index.test.jsx +++ b/src/containers/CourseList/index.test.jsx @@ -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(); diff --git a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap index fb763df..30cc0f3 100644 --- a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap +++ b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap @@ -10,7 +10,7 @@ exports[`Dashboard snapshots there are available dashboards 1`] = `
`; -exports[`Dashboard snapshots there are courses 1`] = ` +exports[`Dashboard snapshots there are courses, or they are still loading 1`] = `
{ const hasCourses = appHooks.useHasCourses(); const hasAvailableDashboards = appHooks.useHasAvailableDashboards(); const showSelectSessionModal = appHooks.useShowSelectSessionModal(); + const initIsPending = appHooks.useIsPendingRequest(RequestKeys.initialize); + return (
{hasAvailableDashboards && } - {hasCourses ? ( + {initIsPending || (!initIsPending && hasCourses) ? ( ({ 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(); }; 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); diff --git a/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap b/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap index 6434cb7..b9036e4 100644 --- a/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap +++ b/src/containers/MasqueradeBar/__snapshots__/index.test.jsx.snap @@ -20,12 +20,16 @@ exports[`MasqueradeBar snapshot can masquerade 1`] = ` value="" /> - + labels={ + Object { + "default": "Submit", + } + } + state="default" + variant="brand" + />
`; @@ -49,12 +53,16 @@ exports[`MasqueradeBar snapshot can masquerade with input 1`] = ` value="test" /> - + labels={ + Object { + "default": "Submit", + } + } + state="default" + variant="brand" + />
`; @@ -80,17 +88,55 @@ exports[`MasqueradeBar snapshot is masquerading failed with error 1`] = ` value="" /> test-error -
+`; + +exports[`MasqueradeBar snapshot is masquerading pending 1`] = ` +
+ - Submit - + View as: + + + + +
`; diff --git a/src/containers/MasqueradeBar/hooks.js b/src/containers/MasqueradeBar/hooks.js index 54196cf..1142945 100644 --- a/src/containers/MasqueradeBar/hooks.js +++ b/src/containers/MasqueradeBar/hooks.js @@ -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, diff --git a/src/containers/MasqueradeBar/hooks.test.js b/src/containers/MasqueradeBar/hooks.test.js index ab0773a..01ebdd9 100644 --- a/src/containers/MasqueradeBar/hooks.test.js +++ b/src/containers/MasqueradeBar/hooks.test.js @@ -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(); }); diff --git a/src/containers/MasqueradeBar/index.jsx b/src/containers/MasqueradeBar/index.jsx index 20eb2ab..2cf8fe1 100644 --- a/src/containers/MasqueradeBar/index.jsx +++ b/src/containers/MasqueradeBar/index.jsx @@ -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 && ( - + {masqueradeError} )} - + labels={{ + default: formatMessage(messages.SubmitButton), + }} + state={isMasqueradingPending ? 'pending' : 'default'} + /> )}
diff --git a/src/containers/MasqueradeBar/index.test.jsx b/src/containers/MasqueradeBar/index.test.jsx index f3704e7..84382e5 100644 --- a/src/containers/MasqueradeBar/index.test.jsx +++ b/src/containers/MasqueradeBar/index.test.jsx @@ -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()).toMatchSnapshot(); }); + test('is masquerading pending', () => { + hooks.useMasqueradeBarData.mockReturnValueOnce({ + ...masqueradeMockData, + isMasqueradingPending: true, + }); + expect(shallow()).toMatchSnapshot(); + }); }); }); diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js index f24e325..2ad2aab 100644 --- a/src/data/redux/hooks.js +++ b/src/data/redux/hooks.js @@ -40,3 +40,5 @@ export const useUpdateSelectSessionModalCallback = (dispatch, cardId) => () => d ); export const useMasqueradeData = () => useSelector(requestSelectors.masquerade); + +export const useIsPendingRequest = (requestName) => useSelector(requestSelectors.isPending(requestName)); diff --git a/src/data/redux/requests/selectors.js b/src/data/redux/requests/selectors.js index 4c6278a..c2a5192 100644 --- a/src/data/redux/requests/selectors.js +++ b/src/data/redux/requests/selectors.js @@ -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), }; }; diff --git a/src/data/redux/requests/selectors.test.js b/src/data/redux/requests/selectors.test.js index 9ee715f..4bf3c92 100644 --- a/src/data/redux/requests/selectors.test.js +++ b/src/data/redux/requests/selectors.test.js @@ -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, }); });