diff --git a/src/containers/Dashboard/DashboardLayout.jsx b/src/containers/Dashboard/DashboardLayout.jsx index 08e7809..8d8bf1e 100644 --- a/src/containers/Dashboard/DashboardLayout.jsx +++ b/src/containers/Dashboard/DashboardLayout.jsx @@ -8,8 +8,14 @@ import hooks from './hooks'; export const columnConfig = { courseList: { - lg: { span: 12, offset: 0 }, - xl: { span: 8, offset: 0 }, + withSidebar: { + lg: { span: 12, offset: 0 }, + xl: { span: 8, offset: 0 }, + }, + noSidebar: { + lg: { span: 12, offset: 0 }, + xl: { span: 12, offset: 0 }, + }, }, sidebar: { lg: { span: 12, offset: 0 }, @@ -17,18 +23,26 @@ export const columnConfig = { }, }; -export const DashboardLayout = ({ children, sidebar }) => { - const isCollapsed = hooks.useIsDashboardCollapsed(); +export const DashboardLayout = ({ children, sidebar: Sidebar }) => { + const { + isCollapsed, + sidebarShowing, + setSidebarShowing, + } = hooks.useDashboardLayoutData(); + + const courseListColumnProps = sidebarShowing + ? columnConfig.courseList.withSidebar + : columnConfig.courseList.noSidebar; return ( - + {children} {!isCollapsed && (

 

)} - {sidebar} +
@@ -41,7 +55,7 @@ export const DashboardLayout = ({ children, sidebar }) => { }; DashboardLayout.propTypes = { children: PropTypes.node.isRequired, - sidebar: PropTypes.node.isRequired, + sidebar: PropTypes.func.isRequired, }; export default DashboardLayout; diff --git a/src/containers/Dashboard/DashboardLayout.test.jsx b/src/containers/Dashboard/DashboardLayout.test.jsx index 27dacb6..28aa021 100644 --- a/src/containers/Dashboard/DashboardLayout.test.jsx +++ b/src/containers/Dashboard/DashboardLayout.test.jsx @@ -1,3 +1,4 @@ +import React from 'react'; import { shallow } from 'enzyme'; import { Col, Row } from '@edx/paragon'; @@ -6,60 +7,119 @@ import hooks from './hooks'; import DashboardLayout, { columnConfig } from './DashboardLayout'; jest.mock('./hooks', () => ({ - useIsDashboardCollapsed: jest.fn(() => true), + useDashboardLayoutData: jest.fn(), })); +const hookProps = { + isCollapsed: true, + sidebarShowing: false, + setSidebarShowing: jest.fn().mockName('hooks.setSidebarShowing'), +}; +hooks.useDashboardLayoutData.mockReturnValue(hookProps); + +const props = { + sidebar: jest.fn(() => 'test-sidebar-content'), +}; + +const children = 'test-children'; + +let el; describe('DashboardLayout', () => { - const children = 'test-children'; - const props = { - sidebar: 'test-sidebar-content', - }; - const render = () => shallow({children}); + beforeEach(() => { + jest.clearAllMocks(); + el = shallow({children}); + }); + const testColumns = () => { it('loads courseList and sidebar column layout', () => { - const columns = render().find(Row).find(Col); - Object.keys(columnConfig.courseList).forEach(size => { - expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList[size]); - }); + const columns = el.find(Row).find(Col); Object.keys(columnConfig.sidebar).forEach(size => { expect(columns.at(1).props()[size]).toEqual(columnConfig.sidebar[size]); }); }); it('displays children in first column', () => { - const columns = render().find(Row).find(Col); + const columns = el.find(Row).find(Col); expect(columns.at(0).contains(children)).toEqual(true); }); it('displays sidebar prop in second column', () => { - const columns = render().find(Row).find(Col); - expect(columns.at(1).contains(props.sidebar)).toEqual(true); + const columns = el.find(Row).find(Col); + expect(columns.at(1).find(props.sidebar)).toHaveLength(1); }); it('displays a footer in the second row', () => { - const columns = render().find(Row).at(1).find(Col); + const columns = el.find(Row).at(1).find(Col); expect(columns.at(0).containsMatchingElement()).toBeTruthy(); }); }; + const testSidebarLayout = () => { + it('displays widthSidebar width for course list column', () => { + const columns = el.find(Row).find(Col); + Object.keys(columnConfig.courseList.withSidebar).forEach(size => { + expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.withSidebar[size]); + }); + }); + }; + const testNoSidebarLayout = () => { + it('displays noSidebar width for course list column', () => { + const columns = el.find(Row).find(Col); + Object.keys(columnConfig.courseList.noSidebar).forEach(size => { + expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.noSidebar[size]); + }); + }); + }; const testSnapshot = () => { test('snapshot', () => { - expect(render()).toMatchSnapshot(); + expect(el).toMatchSnapshot(); }); }; describe('collapsed', () => { - testColumns(); - testSnapshot(); + describe('sidebar showing', () => { + beforeEach(() => { + hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, sidebarShowing: true }); + }); + testColumns(); + testSnapshot(); + testSidebarLayout(); + }); + describe('sidebar not showing', () => { + testColumns(); + testSnapshot(); + testNoSidebarLayout(); + }); it('does not show spacer component above widget sidebar', () => { - const columns = render().find(Col); + const columns = el.find(Col); expect(columns.at(1).find('h2').length).toEqual(0); }); }); describe('not collapsed', () => { - beforeEach(() => { hooks.useIsDashboardCollapsed.mockReturnValueOnce(false); }); - testColumns(); - testSnapshot(); - it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => { - const columns = render().find(Col); - // nonbreaking space equivalent - expect(columns.at(1).find('h2').text()).toEqual('\xA0'); + const testWidgetSpacing = () => { + it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => { + const columns = el.find(Col); + // nonbreaking space equivalent + expect(columns.at(1).find('h2').text()).toEqual('\xA0'); + }); + }; + describe('sidebar showing', () => { + beforeEach(() => { + hooks.useDashboardLayoutData.mockReturnValueOnce({ + ...hookProps, + isCollapsed: false, + sidebarShowing: true, + }); + }); + testColumns(); + testSnapshot(); + testSidebarLayout(); + testWidgetSpacing(); + }); + describe('sidebar not showing', () => { + beforeEach(() => { + hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, isCollapsed: false }); + }); + testColumns(); + testSnapshot(); + testNoSidebarLayout(); + testWidgetSpacing(); }); }); }); diff --git a/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap index 10528f2..fe139a2 100644 --- a/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap +++ b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap @@ -1,6 +1,57 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DashboardLayout collapsed snapshot 1`] = ` +exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = ` + + + + test-children + + + + + + + + + + + +`; + +exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = ` - test-sidebar-content + @@ -49,7 +102,63 @@ exports[`DashboardLayout collapsed snapshot 1`] = `
`; -exports[`DashboardLayout not collapsed snapshot 1`] = ` +exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = ` + + + + test-children + + +

+   +

+ + +
+ + + + + +
+`; + +exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `   - test-sidebar-content + diff --git a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap index 6e673bd..9382482 100644 --- a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap +++ b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap @@ -15,7 +15,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail id="dashboard-content" > } + sidebar="LoadedWidgetSidebar" > @@ -56,7 +56,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard id="dashboard-content" > } + sidebar="NoCoursesWidgetSidebar" > diff --git a/src/containers/Dashboard/hooks.js b/src/containers/Dashboard/hooks.js index cdb7796..6af3f7a 100644 --- a/src/containers/Dashboard/hooks.js +++ b/src/containers/Dashboard/hooks.js @@ -2,13 +2,14 @@ import React from 'react'; import { useWindowSize, breakpoints } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { apiHooks } from 'hooks'; +import { StrictDict } from 'utils'; import appMessages from 'messages'; +import * as module from './hooks'; -export const useIsDashboardCollapsed = () => { - const { width } = useWindowSize(); - return width < breakpoints.large.maxWidth; -}; +export const state = StrictDict({ + sidebarShowing: (val) => React.useState(val), // eslint-disable-line +}); export const useInitializeDashboard = () => { const initialize = apiHooks.useInitializeApp(); @@ -23,8 +24,18 @@ export const useDashboardMessages = () => { }; }; +export const useDashboardLayoutData = () => { + const { width } = useWindowSize(); + const [sidebarShowing, setSidebarShowing] = module.state.sidebarShowing(false); + return { + isDashboardCollapsed: width < breakpoints.large.maxWidth, + sidebarShowing, + setSidebarShowing, + }; +}; + export default { - useIsDashboardCollapsed, + useDashboardLayoutData, useInitializeDashboard, useDashboardMessages, }; diff --git a/src/containers/Dashboard/hooks.test.js b/src/containers/Dashboard/hooks.test.js index 158310c..bad6927 100644 --- a/src/containers/Dashboard/hooks.test.js +++ b/src/containers/Dashboard/hooks.test.js @@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useWindowSize, breakpoints } from '@edx/paragon'; import { apiHooks } from 'hooks'; +import { MockUseState } from 'testUtils'; import appMessages from 'messages'; import * as hooks from './hooks'; @@ -19,8 +20,12 @@ jest.mock('hooks', () => ({ }, })); +const state = new MockUseState(hooks); + const initializeApp = jest.fn(); apiHooks.useInitializeApp.mockReturnValue(initializeApp); +useWindowSize.mockReturnValue({ width: 20 }); +breakpoints.large = { maxWidth: 30 }; describe('CourseCard hooks', () => { const { formatMessage } = useIntl(); @@ -28,15 +33,32 @@ describe('CourseCard hooks', () => { jest.clearAllMocks(); }); - describe('useIsDashboardCollapsed', () => { - it('returns true iff windowSize width is below the xl breakpoint', () => { - useWindowSize.mockReturnValueOnce({ width: 20 }); - breakpoints.large = { maxWidth: 30 }; - expect(hooks.useIsDashboardCollapsed()).toEqual(true); - useWindowSize.mockReturnValueOnce({ width: 40 }); - expect(hooks.useIsDashboardCollapsed()).toEqual(false); - useWindowSize.mockReturnValueOnce({ width: 40 }); - expect(hooks.useIsDashboardCollapsed()).toEqual(false); + describe('state fields', () => { + state.testGetter(state.keys.sidebarShowing); + }); + + describe('useDashboardLayoutData', () => { + beforeEach(() => { state.mock(); }); + describe('behavior', () => { + it('initializes sidebarShowing to default false value', () => { + hooks.useDashboardLayoutData(); + state.expectInitializedWith(state.keys.sidebarShowing, false); + }); + }); + describe('output', () => { + describe('isDashboardCollapsed', () => { + it('returns true iff windowSize width is below the xl breakpoint', () => { + expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(true); + useWindowSize.mockReturnValueOnce({ width: 40 }); + expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(false); + }); + }); + it('forwards sidebarShowing and setSidebarShowing from state hook', () => { + const hook = hooks.useDashboardLayoutData(); + const { sidebarShowing, setSidebarShowing } = hook; + expect(sidebarShowing).toEqual(state.stateVals.sidebarShowing); + expect(setSidebarShowing).toEqual(state.setState.sidebarShowing); + }); }); }); describe('useInitializeDashboard', () => { diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx index f7b3695..078c0bb 100644 --- a/src/containers/Dashboard/index.jsx +++ b/src/containers/Dashboard/index.jsx @@ -35,7 +35,7 @@ export const Dashboard = () => { {initIsPending ? () : ( - : }> + )} diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx index 2a50cd9..7012255 100644 --- a/src/containers/Dashboard/index.test.jsx +++ b/src/containers/Dashboard/index.test.jsx @@ -116,7 +116,7 @@ describe('Dashboard', () => { showSelectSessionModal: true, }, content: ['LoadedView', ( - }> + )], showEnterpriseModal: false, showSelectSessionModal: true, @@ -132,7 +132,7 @@ describe('Dashboard', () => { showSelectSessionModal: false, }, content: ['Dashboard layout with no courses sidebar and content', ( - }> + )], showEnterpriseModal: true, showSelectSessionModal: false, diff --git a/src/containers/WidgetContainers/LoadedSidebar/index.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.jsx index 582632a..6dc0c5c 100644 --- a/src/containers/WidgetContainers/LoadedSidebar/index.jsx +++ b/src/containers/WidgetContainers/LoadedSidebar/index.jsx @@ -1,12 +1,15 @@ import React from 'react'; +import PropTypes from 'prop-types'; import RecommendationsPanel from 'widgets/RecommendationsPanel'; import hooks from 'widgets/ProductRecommendations/hooks'; -export const WidgetSidebar = () => { - const showRecommendationsFooter = hooks.useShowRecommendationsFooter(); +export const WidgetSidebar = ({ setSidebarShowing }) => { + const { shouldShowFooter } = hooks.useShowRecommendationsFooter(); + + if (!shouldShowFooter) { + setSidebarShowing(true); - if (!showRecommendationsFooter) { return (
@@ -19,4 +22,8 @@ export const WidgetSidebar = () => { return null; }; +WidgetSidebar.propTypes = { + setSidebarShowing: PropTypes.func.isRequired, +}; + export default WidgetSidebar; diff --git a/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx index be4af29..b9bffdb 100644 --- a/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx +++ b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx @@ -1,6 +1,7 @@ import { shallow } from 'enzyme'; import hooks from 'widgets/ProductRecommendations/hooks'; +import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData'; import WidgetSidebar from '.'; jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); @@ -9,17 +10,28 @@ jest.mock('widgets/ProductRecommendations/hooks', () => ({ })); describe('WidgetSidebar', () => { + beforeEach(() => jest.resetAllMocks()); + const props = { + setSidebarShowing: jest.fn(), + }; + describe('snapshots', () => { test('default', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(false); - const wrapper = shallow(); + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.dontShowOrLoad, + ); + const wrapper = shallow(); + expect(props.setSidebarShowing).toHaveBeenCalledWith(true); expect(wrapper).toMatchSnapshot(); }); }); test('is hidden if footer is shown', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(true); - const wrapper = shallow(); + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.showDontLoad, + ); + const wrapper = shallow(); + expect(props.setSidebarShowing).not.toHaveBeenCalled(); expect(wrapper.type()).toBeNull(); }); }); diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx index 21b8266..091940b 100644 --- a/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx +++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx @@ -1,12 +1,15 @@ import React from 'react'; +import PropTypes from 'prop-types'; import RecommendationsPanel from 'widgets/RecommendationsPanel'; import hooks from 'widgets/ProductRecommendations/hooks'; -export const WidgetSidebar = () => { - const showRecommendationsFooter = hooks.useShowRecommendationsFooter(); +export const WidgetSidebar = ({ setSidebarShowing }) => { + const { shouldShowFooter } = hooks.useShowRecommendationsFooter(); + + if (!shouldShowFooter) { + setSidebarShowing(true); - if (!showRecommendationsFooter) { return (
@@ -19,4 +22,8 @@ export const WidgetSidebar = () => { return null; }; +WidgetSidebar.propTypes = { + setSidebarShowing: PropTypes.func.isRequired, +}; + export default WidgetSidebar; diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx index be4af29..b9bffdb 100644 --- a/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx +++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx @@ -1,6 +1,7 @@ import { shallow } from 'enzyme'; import hooks from 'widgets/ProductRecommendations/hooks'; +import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData'; import WidgetSidebar from '.'; jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); @@ -9,17 +10,28 @@ jest.mock('widgets/ProductRecommendations/hooks', () => ({ })); describe('WidgetSidebar', () => { + beforeEach(() => jest.resetAllMocks()); + const props = { + setSidebarShowing: jest.fn(), + }; + describe('snapshots', () => { test('default', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(false); - const wrapper = shallow(); + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.dontShowOrLoad, + ); + const wrapper = shallow(); + expect(props.setSidebarShowing).toHaveBeenCalledWith(true); expect(wrapper).toMatchSnapshot(); }); }); test('is hidden if footer is shown', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(true); - const wrapper = shallow(); + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.showDontLoad, + ); + const wrapper = shallow(); + expect(props.setSidebarShowing).not.toHaveBeenCalled(); expect(wrapper.type()).toBeNull(); }); }); diff --git a/src/containers/WidgetContainers/WidgetFooter/index.jsx b/src/containers/WidgetContainers/WidgetFooter/index.jsx index 86dbd52..45b6ace 100644 --- a/src/containers/WidgetContainers/WidgetFooter/index.jsx +++ b/src/containers/WidgetContainers/WidgetFooter/index.jsx @@ -4,9 +4,9 @@ import ProductRecommendations from 'widgets/ProductRecommendations'; import hooks from 'widgets/ProductRecommendations/hooks'; export const WidgetFooter = () => { - const showRecommendationsFooter = hooks.useShowRecommendationsFooter(); + const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter(); - if (showRecommendationsFooter) { + if (shouldShowFooter && shouldLoadFooter) { return (
diff --git a/src/containers/WidgetContainers/WidgetFooter/index.test.jsx b/src/containers/WidgetContainers/WidgetFooter/index.test.jsx index 9854462..7c73936 100644 --- a/src/containers/WidgetContainers/WidgetFooter/index.test.jsx +++ b/src/containers/WidgetContainers/WidgetFooter/index.test.jsx @@ -1,6 +1,7 @@ import { shallow } from 'enzyme'; import hooks from 'widgets/ProductRecommendations/hooks'; +import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData'; import WidgetFooter from '.'; jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); @@ -11,14 +12,26 @@ jest.mock('widgets/ProductRecommendations/hooks', () => ({ describe('WidgetFooter', () => { describe('snapshots', () => { test('default', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(true); + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.showAndLoad, + ); const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); - test('is hidden when hook returns false', () => { - hooks.useShowRecommendationsFooter.mockReturnValueOnce(false); + test('is hidden when shouldShowFooter is false but shouldLoadFooter is true', () => { + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.loadDontShow, + ); + const wrapper = shallow(); + expect(wrapper.type()).toBeNull(); + }); + + test('is hidden when shouldLoadFooter is false but shouldShowFooter is true', () => { + hooks.useShowRecommendationsFooter.mockReturnValueOnce( + mockFooterRecommendationsHook.showDontLoad, + ); const wrapper = shallow(); expect(wrapper.type()).toBeNull(); }); diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 0cbd1fb..60b4af2 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -14,6 +14,7 @@ jest.mock('react', () => ({ useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })), useMemo: jest.fn((cb, prereqs) => cb(prereqs)), useContext: jest.fn(context => context), + useState: jest.fn(), })); jest.mock('reselect', () => ({ diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index 2b51b81..6a38aa6 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -42,8 +42,8 @@ jest.unmock('react-redux'); jest.unmock('reselect'); jest.unmock('hooks'); -jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'loaded-widget-sidebar'); -jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'no-courses-widget-sidebar'); +jest.mock('containers/WidgetContainers/LoadedSidebar', () => jest.fn(() => 'loaded-widget-sidebar')); +jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => jest.fn(() => 'no-courses-widget-sidebar')); jest.mock('components/NoticesWrapper', () => 'notices-wrapper'); jest.mock('@edx/frontend-platform', () => ({ diff --git a/src/widgets/ProductRecommendations/api.js b/src/widgets/ProductRecommendations/api.js index f216070..827e7f3 100644 --- a/src/widgets/ProductRecommendations/api.js +++ b/src/widgets/ProductRecommendations/api.js @@ -1,10 +1,15 @@ import { get, stringifyUrl } from 'data/services/lms/utils'; import urls from 'data/services/lms/urls'; -export const productRecommendationsUrl = (courseId) => `${urls.api}/learner_recommendations/product_recommendations/${courseId}/`; +export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/${courseId}/`; +export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/`; -const fetchProductRecommendations = (courseId) => get(stringifyUrl(productRecommendationsUrl(courseId))); +const fetchCrossProductRecommendations = (courseId) => ( + get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId))) +); +const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommendationsUrl())); export default { - fetchProductRecommendations, + fetchCrossProductRecommendations, + fetchAmplitudeRecommendations, }; diff --git a/src/widgets/ProductRecommendations/api.test.js b/src/widgets/ProductRecommendations/api.test.js index 3be3f9e..5bce09f 100644 --- a/src/widgets/ProductRecommendations/api.test.js +++ b/src/widgets/ProductRecommendations/api.test.js @@ -1,5 +1,6 @@ import { get, stringifyUrl } from 'data/services/lms/utils'; -import api, { productRecommendationsUrl } from './api'; + +import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl } from './api'; jest.mock('data/services/lms/utils', () => ({ stringifyUrl: (...args) => ({ stringifyUrl: args }), @@ -7,10 +8,18 @@ jest.mock('data/services/lms/utils', () => ({ })); describe('productRecommendationCourses api', () => { - describe('fetchProductRecommendations', () => { + describe('fetchCrossProductRecommendations', () => { it('calls get with the correct recommendation courses URL', () => { - expect(api.fetchProductRecommendations('CourseRunKey')).toEqual( - get(stringifyUrl(productRecommendationsUrl('CourseRunKey'))), + expect(api.fetchCrossProductRecommendations('CourseRunKey')).toEqual( + get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl('CourseRunKey'))), + ); + }); + }); + + describe('fetchAmplitudeRecommendations', () => { + it('calls get with the correct recommendation courses URL', () => { + expect(api.fetchAmplitudeRecommendations()).toEqual( + get(stringifyUrl(amplitudeRecommendationsUrl())), ); }); }); diff --git a/src/widgets/ProductRecommendations/hooks.js b/src/widgets/ProductRecommendations/hooks.js index a97da65..61b3cff 100644 --- a/src/widgets/ProductRecommendations/hooks.js +++ b/src/widgets/ProductRecommendations/hooks.js @@ -13,42 +13,56 @@ export const state = StrictDict({ }); export const useShowRecommendationsFooter = () => { - const hasCourses = reduxHooks.useHasCourses(); const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards(); - const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize); + const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize); // Hardcoded to not show until experiment related code is implemented - return !initIsPending && hasCourses && !hasAvailableDashboards && false; + return { + shouldShowFooter: false, + shouldLoadFooter: hasRequestCompleted && !hasAvailableDashboards, + }; }; export const useMostRecentCourseRunKey = () => { - const mostRecentCourse = reduxHooks.useCurrentCourseList({ + const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({ sortBy: SortKeys.enrolled, filters: [], pageSize: 0, - }).visible[0].courseRun.courseId; + }).visible[0]?.courseRun?.courseId; - return mostRecentCourse; + return mostRecentCourseRunKey; }; -export const useFetchProductRecommendations = (setRequestState, setData) => { +export const useFetchRecommendations = (setRequestState, setData) => { const courseRunKey = module.useMostRecentCourseRunKey(); useEffect(() => { let isMounted = true; - api - .fetchProductRecommendations(courseRunKey) - .then((response) => { - if (isMounted) { - setData(response.data); - setRequestState(RequestStates.completed); - } - }) - .catch(() => { - if (isMounted) { - setRequestState(RequestStates.failed); - } - }); + + const handleSuccess = (response) => { + if (isMounted) { + setData(response.data); + setRequestState(RequestStates.completed); + } + }; + + const handleError = () => { + if (isMounted) { + setRequestState(RequestStates.failed); + } + }; + + if (courseRunKey) { + api + .fetchCrossProductRecommendations(courseRunKey) + .then(handleSuccess) + .catch(handleError); + } else { + api + .fetchAmplitudeRecommendations() + .then(handleSuccess) + .catch(handleError); + } return () => { isMounted = false; }; @@ -59,7 +73,7 @@ export const useFetchProductRecommendations = (setRequestState, setData) => { export const useProductRecommendationsData = () => { const [requestState, setRequestState] = module.state.requestState(RequestStates.pending); const [data, setData] = module.state.data({}); - module.useFetchProductRecommendations(setRequestState, setData); + module.useFetchRecommendations(setRequestState, setData); return { productRecommendations: data, diff --git a/src/widgets/ProductRecommendations/hooks.test.js b/src/widgets/ProductRecommendations/hooks.test.js index 1531997..8ed332d 100644 --- a/src/widgets/ProductRecommendations/hooks.test.js +++ b/src/widgets/ProductRecommendations/hooks.test.js @@ -10,15 +10,15 @@ import api from './api'; import * as hooks from './hooks'; jest.mock('./api', () => ({ - fetchProductRecommendations: jest.fn(), + fetchCrossProductRecommendations: jest.fn(), + fetchAmplitudeRecommendations: jest.fn(), })); jest.mock('hooks', () => ({ reduxHooks: { useCurrentCourseList: jest.fn(), - useHasCourses: jest.fn(), useHasAvailableDashboards: jest.fn(), - useRequestIsPending: jest.fn(), + useRequestIsCompleted: jest.fn(), }, })); @@ -38,11 +38,16 @@ const courses = [ }, ]; -const courseListData = { +const populatedCourseListData = { visible: courses, numPages: 0, }; +const emptyCourseListData = { + visible: [], + numPages: 0, +}; + let output; describe('ProductRecommendations hooks', () => { beforeEach(() => { @@ -56,7 +61,7 @@ describe('ProductRecommendations hooks', () => { describe('useMostRecentCourseRunKey', () => { it('returns the courseId of the first course in the sorted visible array', () => { - reduxHooks.useCurrentCourseList.mockReturnValueOnce(courseListData); + reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData); expect(hooks.useMostRecentCourseRunKey()).toBe(mostRecentCourseRunKey); }); @@ -64,16 +69,17 @@ describe('ProductRecommendations hooks', () => { describe('useShowRecommendationsFooter', () => { // TODO: Update when hardcoded value is removed - it('returns whether the footer widget should show', () => { - reduxHooks.useHasCourses.mockReturnValueOnce(true); + it('returns whether the footer widget should show and should load', () => { reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false); - reduxHooks.useRequestIsPending.mockReturnValueOnce(false); + reduxHooks.useRequestIsCompleted.mockReturnValueOnce(true); + const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter(); - expect(hooks.useShowRecommendationsFooter()).toBeFalsy(); + expect(shouldShowFooter).toBeFalsy(); + expect(shouldLoadFooter).toBeTruthy(); }); }); - describe('useFetchProductRecommendations', () => { + describe('useFetchRecommendations', () => { describe('behavior', () => { describe('useEffect call', () => { let calls; @@ -81,86 +87,176 @@ describe('ProductRecommendations hooks', () => { const response = { data: 'response data' }; const setRequestState = jest.fn(); const setData = jest.fn(); - beforeEach(() => { - reduxHooks.useCurrentCourseList.mockReturnValue(courseListData); - hooks.useFetchProductRecommendations(setRequestState, setData); + + const setUp = (mockCourseListData) => { + reduxHooks.useCurrentCourseList.mockReturnValue(mockCourseListData); + hooks.useFetchRecommendations(setRequestState, setData); ({ calls } = React.useEffect.mock); ([[cb]] = calls); - }); + }; + it('calls useEffect once', () => { + setUp(populatedCourseListData); expect(calls.length).toEqual(1); }); - it('calls fetchProductRecommendations with the most recently enrolled courseId', () => { - api.fetchProductRecommendations.mockReturnValueOnce(Promise.resolve(response)); - cb(); - expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); - }); - describe('successful fetch on mounted component', () => { - it('sets the request state to completed and loads response', async () => { - let resolveFn; - api.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => { - resolveFn = resolve; - })); + describe('without no courseId due to no enrolled courses', () => { + it('calls fetchAmplitudeRecommendations', () => { + setUp(emptyCourseListData); + api.fetchAmplitudeRecommendations.mockReturnValueOnce(Promise.resolve(response)); cb(); - expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); - resolveFn(response); - await waitFor(() => { - expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed); - expect(setData).toHaveBeenCalledWith(response.data); + expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled(); + }); + }); + describe('with most recently enrolled courseId', () => { + it('calls fetchCrossProductRecommendations with the most recently enrolled courseId', () => { + setUp(populatedCourseListData); + api.fetchCrossProductRecommendations.mockReturnValueOnce(Promise.resolve(response)); + cb(); + expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); + }); + }); + describe('fetching cross product recommendations', () => { + beforeEach(() => setUp(populatedCourseListData)); + + describe('successful fetch on mounted component', () => { + it('sets the request state to completed and loads response', async () => { + let resolveFn; + api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + cb(); + expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + resolveFn(response); + await waitFor(() => { + expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed); + expect(setData).toHaveBeenCalledWith(response.data); + }); }); }); - }); - describe('successful fetch on unmounted component', () => { - it('does not set the state', async () => { - let resolveFn; - api.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => { - resolveFn = resolve; - })); - const unMount = cb(); - expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); - unMount(); - resolveFn(response); - await wait(10); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); + describe('successful fetch on unmounted component', () => { + it('does not set the state', async () => { + let resolveFn; + api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + const unMount = cb(); + expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + unMount(); + resolveFn(response); + await wait(10); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + }); }); - }); - describe('unsuccessful fetch on mounted component', () => { - it('sets the request state to failed and does not set the data state', async () => { - let rejectFn; - api.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { - rejectFn = reject; - })); - cb(); - expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); - rejectFn(); - await waitFor(() => { - expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed); + describe('unsuccessful fetch on mounted component', () => { + it('sets the request state to failed and does not set the data state', async () => { + let rejectFn; + api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { + rejectFn = reject; + })); + cb(); + expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + rejectFn(); + await waitFor(() => { + expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed); + expect(setData).not.toHaveBeenCalled(); + }); + }); + }); + describe('unsuccessful fetch on unmounted component', () => { + it('does not set the state', async () => { + let rejectFn; + api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { + rejectFn = reject; + })); + const unMount = cb(); + expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + unMount(); + rejectFn(); + await wait(10); + expect(setRequestState).not.toHaveBeenCalled(); expect(setData).not.toHaveBeenCalled(); }); }); }); - describe('unsuccessful fetch on unmounted component', () => { - it('does not set the state', async () => { - let rejectFn; - api.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { - rejectFn = reject; - })); - const unMount = cb(); - expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); - unMount(); - rejectFn(); - await wait(10); - expect(setRequestState).not.toHaveBeenCalled(); - expect(setData).not.toHaveBeenCalled(); + describe('fetching Amplitude recommendations', () => { + beforeEach(() => setUp(emptyCourseListData)); + + describe('successful fetch on mounted component', () => { + it('sets the request state to completed and loads response', async () => { + let resolveFn; + api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + cb(); + expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + resolveFn(response); + await waitFor(() => { + expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed); + expect(setData).toHaveBeenCalledWith(response.data); + }); + }); + }); + describe('successful fetch on unmounted component', () => { + it('does not set the state', async () => { + let resolveFn; + api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + const unMount = cb(); + expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + unMount(); + resolveFn(response); + await wait(10); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + }); + }); + describe('unsuccessful fetch on mounted component', () => { + it('sets the request state to failed and does not set the data state', async () => { + let rejectFn; + api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { + rejectFn = reject; + })); + cb(); + expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + rejectFn(); + await waitFor(() => { + expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed); + expect(setData).not.toHaveBeenCalled(); + }); + }); + }); + describe('unsuccessful fetch on unmounted component', () => { + it('does not set the state', async () => { + let rejectFn; + api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => { + rejectFn = reject; + })); + const unMount = cb(); + expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + unMount(); + rejectFn(); + await wait(10); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + }); }); }); }); @@ -170,10 +266,10 @@ describe('ProductRecommendations hooks', () => { let fetchSpy; beforeEach(() => { state.mock(); - fetchSpy = jest.spyOn(hooks, 'useFetchProductRecommendations').mockImplementationOnce(() => {}); + fetchSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {}); output = hooks.useProductRecommendationsData(); }); - it('calls useFetchProductRecommendations with setRequestState and setData', () => { + it('calls useFetchRecommendations with setRequestState and setData', () => { expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data); }); it('initializes requestState as RequestStates.pending', () => { diff --git a/src/widgets/ProductRecommendations/index.jsx b/src/widgets/ProductRecommendations/index.jsx index 467ca3b..f0dea2f 100644 --- a/src/widgets/ProductRecommendations/index.jsx +++ b/src/widgets/ProductRecommendations/index.jsx @@ -18,7 +18,7 @@ const ProductRecommendations = () => { return ( ); } diff --git a/src/widgets/ProductRecommendations/index.test.jsx b/src/widgets/ProductRecommendations/index.test.jsx index c100cc4..f85a480 100644 --- a/src/widgets/ProductRecommendations/index.test.jsx +++ b/src/widgets/ProductRecommendations/index.test.jsx @@ -6,7 +6,7 @@ import hooks from './hooks'; import ProductRecommendations from './index'; import LoadingView from './components/LoadingView'; import LoadedView from './components/LoadedView'; -import { mockResponse } from './testData'; +import { mockCrossProductResponse, mockAmplitudeResponse } from './testData'; jest.mock('./hooks', () => ({ useProductRecommendationsData: jest.fn(), @@ -25,7 +25,7 @@ describe('ProductRecommendations', () => { const successfullLoadValues = { ...defaultValues, isLoaded: true, - productRecommendations: mockResponse, + productRecommendations: mockCrossProductResponse, }; const desktopWindowSize = { @@ -41,7 +41,7 @@ describe('ProductRecommendations', () => { expect(shallow()).toMatchSnapshot(); }); - it('renders the LoadedView with course data if the request completed', () => { + it('renders the LoadedView with cross product data if the request completed', () => { useWindowSize.mockReturnValueOnce(desktopWindowSize); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, @@ -50,8 +50,24 @@ describe('ProductRecommendations', () => { expect(shallow()).toMatchObject( shallow( , + ), + ); + }); + it('renders the LoadedView with Amplitude course data if the request completed', () => { + useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useProductRecommendationsData.mockReturnValueOnce({ + ...successfullLoadValues, + productRecommendations: mockAmplitudeResponse, + }); + + expect(shallow()).toMatchObject( + shallow( + , ), ); diff --git a/src/widgets/ProductRecommendations/testData.js b/src/widgets/ProductRecommendations/testData.js index 71e233f..ebc9bbd 100644 --- a/src/widgets/ProductRecommendations/testData.js +++ b/src/widgets/ProductRecommendations/testData.js @@ -22,10 +22,21 @@ export const getCoursesWithType = (courseTypes) => { return courses; }; +export const mockFooterRecommendationsHook = { + showAndLoad: { shouldShowFooter: true, shouldLoadFooter: true }, + showDontLoad: { shouldShowFooter: true, shouldLoadFooter: false }, + loadDontShow: { shouldShowFooter: false, shouldLoadFooter: true }, + dontShowOrLoad: { shouldShowFooter: false, shouldLoadFooter: false }, +}; + export const mockCrossProductCourses = getCoursesWithType(['executive-education-2u', 'bootcamp-2u']); export const mockOpenCourses = getCoursesWithType(['verified-audit', 'audit', 'verified', 'course']); -export const mockResponse = { +export const mockCrossProductResponse = { crossProductCourses: mockCrossProductCourses, amplitudeCourses: mockOpenCourses, }; + +export const mockAmplitudeResponse = { + amplitudeCourses: mockOpenCourses, +};