diff --git a/src/assets/empty-course.svg b/src/assets/empty-course.svg index a96d495..98294bb 100644 --- a/src/assets/empty-course.svg +++ b/src/assets/empty-course.svg @@ -1,9 +1,49 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/containers/CourseCard/components/CourseCardBanners/__snapshots__/CertificateBanner.test.jsx.snap b/src/containers/CourseCard/components/CourseCardBanners/__snapshots__/CertificateBanner.test.jsx.snap index 8bf1652..fd35687 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/__snapshots__/CertificateBanner.test.jsx.snap +++ b/src/containers/CourseCard/components/CourseCardBanners/__snapshots__/CertificateBanner.test.jsx.snap @@ -92,7 +92,7 @@ exports[`CertificateBanner snapshot is restricted and verified 1`] = ` exports[`CertificateBanner snapshot not passing and audit 1`] = ` - Grade required to pass the course: 0.8 + Grade required to pass the course: 0.8‏% `; @@ -114,6 +114,6 @@ exports[`CertificateBanner snapshot not passing and not audit and not finished 1 - Grade required for a certificate: 0.8 + Grade required for a certificate: 0.8‏% `; diff --git a/src/containers/CourseCard/components/CourseCardBanners/messages.js b/src/containers/CourseCard/components/CourseCardBanners/messages.js index 4336697..7a9b8a8 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/messages.js +++ b/src/containers/CourseCard/components/CourseCardBanners/messages.js @@ -39,7 +39,7 @@ export const messages = StrictDict({ passingGrade: { id: 'learner-dash.courseCard.banners.passingGrade', description: 'Message to learners with minimum passing grade for the course', - defaultMessage: 'Grade required to pass the course: {minPassingGrade}', + defaultMessage: 'Grade required to pass the course: {minPassingGrade}\u200f%', }, notEligibleForCert: { id: 'learner-dash.courseCard.banners.notEligibleForCert', @@ -64,7 +64,7 @@ export const messages = StrictDict({ certMinGrade: { id: 'learner-dash.courseCard.banners.certMinGrade', description: 'Passing grade requirement message', - defaultMessage: 'Grade required for a certificate: {minPassingGrade}', + defaultMessage: 'Grade required for a certificate: {minPassingGrade}\u200f%', }, downloadCertificate: { id: 'learner-dash.courseCard.banners.downloadCertificate', diff --git a/src/containers/CourseFilterControls/CourseFilterControls.jsx b/src/containers/CourseFilterControls/CourseFilterControls.jsx index e145b32..4a63f85 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -14,6 +14,8 @@ import { } from '@edx/paragon'; import { Close, Tune } from '@edx/paragon/icons'; +import { hooks as appHooks } from 'data/redux'; + import FilterForm from './components/FilterForm'; import SortForm from './components/SortForm'; import useCourseFilterControlsData from './hooks'; @@ -28,6 +30,7 @@ export const CourseFilterControls = ({ setFilters, }) => { const { formatMessage } = useIntl(); + const hasCourses = appHooks.useHasCourses(); const { isOpen, open, @@ -50,6 +53,7 @@ export const CourseFilterControls = ({ variant="outline-primary" iconBefore={Tune} onClick={open} + disabled={!hasCourses} > {formatMessage(messages.refine)} diff --git a/src/containers/CourseFilterControls/CourseFilterControls.test.jsx b/src/containers/CourseFilterControls/CourseFilterControls.test.jsx index e07a2ba..a832398 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.test.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.test.jsx @@ -1,14 +1,23 @@ import { shallow } from 'enzyme'; import { breakpoints, useWindowSize } from '@edx/paragon'; + +import { hooks as appHooks } from 'data/redux'; + import CourseFilterControls from './CourseFilterControls'; import useCourseFilterControlsData from './hooks'; +jest.mock('data/redux', () => ({ + hooks: { useHasCourses: jest.fn() }, +})); + jest.mock('./hooks', () => jest.fn().mockName('useCourseFilterControlsData')); jest.mock('./components/FilterForm', () => 'FilterForm'); jest.mock('./components/SortForm', () => 'SortForm'); +appHooks.useHasCourses.mockReturnValue(true); + describe('CourseFilterControls', () => { const props = { sortBy: 'test-sort-by', @@ -30,13 +39,23 @@ describe('CourseFilterControls', () => { handleSortChange: jest.fn().mockName('handleSortChange'), }); - describe('snapshot', () => { - test('is mobile', () => { + describe('no courses', () => { + test('snapshot', () => { + appHooks.useHasCourses.mockReturnValueOnce(false); + useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth }); + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); + describe('mobile', () => { + test('snapshot', () => { useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth - 1 }); const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - test('is not mobile', () => { + }); + describe('is not mobile', () => { + test('snapshot', () => { useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth }); const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap b/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap index 3630c56..2b87f21 100644 --- a/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap +++ b/src/containers/CourseFilterControls/__snapshots__/CourseFilterControls.test.jsx.snap @@ -1,10 +1,63 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CourseFilterControls snapshot is mobile 1`] = ` +exports[`CourseFilterControls is not mobile snapshot 1`] = `
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+`; + +exports[`CourseFilterControls mobile snapshot 1`] = ` +
+ +
+`; diff --git a/src/containers/CourseList/NoCoursesView/index.jsx b/src/containers/CourseList/NoCoursesView/index.jsx new file mode 100644 index 0000000..d44649c --- /dev/null +++ b/src/containers/CourseList/NoCoursesView/index.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Image } from '@edx/paragon'; +import { Search } from '@edx/paragon/icons'; + +import emptyCourseSVG from 'assets/empty-course.svg'; + +import { hooks as appHooks } from 'data/redux'; +import messages from './messages'; + +import './index.scss'; + +export const NoCoursesView = () => { + const { courseSearchUrl } = appHooks.usePlatformSettingsData(); + const { formatMessage } = useIntl(); + return ( +
+ {formatMessage(messages.bannerAlt)} +

+ {formatMessage(messages.lookingForChallengePrompt)} +

+

+ {formatMessage(messages.exploreCoursesPrompt)} +

+ +
+ ); +}; + +export default NoCoursesView; diff --git a/src/containers/EmptyCourse/index.scss b/src/containers/CourseList/NoCoursesView/index.scss similarity index 63% rename from src/containers/EmptyCourse/index.scss rename to src/containers/CourseList/NoCoursesView/index.scss index bff7b0f..45f1756 100644 --- a/src/containers/EmptyCourse/index.scss +++ b/src/containers/CourseList/NoCoursesView/index.scss @@ -1,13 +1,15 @@ @import "@edx/paragon/scss/core/core"; -.empty-course-hero { - display: flex; +#no-courses-content-view { + border: 2px solid $light-400; flex-direction: column; - align-items: center; padding-bottom: map-get($spacers, 5); + padding-top: map-get($spacers, 5); + height: 100%; & > * { margin-top: map-get($spacers, 3); margin-bottom: map-get($spacers, 3); } -} \ No newline at end of file +} + diff --git a/src/containers/CourseList/NoCoursesView/index.test.jsx b/src/containers/CourseList/NoCoursesView/index.test.jsx new file mode 100644 index 0000000..5e910d1 --- /dev/null +++ b/src/containers/CourseList/NoCoursesView/index.test.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import EmptyCourse from '.'; + +jest.mock('data/redux', () => ({ + hooks: { + useRecommendedCoursesData: jest.fn(() => ({ courses: [], isPersonalizedRecommendation: false })), + useRequestIsPending: jest.fn(), + usePlatformSettingsData: () => ({ + courseSearchUrl: 'course-search-url', + }), + }, +})); + +jest.mock('containers/Dashboard/hooks', () => ({ + useIsDashboardCollapsed: jest.fn(() => true), +})); + +describe('NoCoursesView', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/src/containers/EmptyCourse/messages.js b/src/containers/CourseList/NoCoursesView/messages.js similarity index 62% rename from src/containers/EmptyCourse/messages.js rename to src/containers/CourseList/NoCoursesView/messages.js index c51356b..ebe25fb 100644 --- a/src/containers/EmptyCourse/messages.js +++ b/src/containers/CourseList/NoCoursesView/messages.js @@ -2,20 +2,25 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ lookingForChallengePrompt: { - id: 'EmptyCourse.lookingForChallengePrompt', + id: 'Dashboard.NoCoursesView.lookingForChallengePrompt', defaultMessage: 'Looking for a new challenge?', description: 'Prompt user for new challenge', }, exploreCoursesPrompt: { - id: 'EmptyCourse.exploreCoursesPrompt', + id: 'Dashboard.NoCoursesView.exploreCoursesPrompt', defaultMessage: 'Explore our courses to add them to your dashboard.', description: 'Prompt user to explore more courses', }, exploreCoursesButton: { - id: 'EmptyCourse.exploreCoursesButton', + id: 'Dashboard.NoCoursesView.exploreCoursesButton', defaultMessage: 'Explore courses', description: 'Button to explore more courses', }, + bannerAlt: { + id: 'Dashboard.NoCoursesView.bannerAlt', + defaultMessage: 'No Courses view banner', + description: 'No Courses view basnner', + }, }); export default messages; diff --git a/src/containers/CourseList/__snapshots__/index.test.jsx.snap b/src/containers/CourseList/__snapshots__/index.test.jsx.snap index 828e7c7..997bcc1 100644 --- a/src/containers/CourseList/__snapshots__/index.test.jsx.snap +++ b/src/containers/CourseList/__snapshots__/index.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CourseList snapshots collapsed with multiple courses and pages 1`] = ` +exports[`CourseList collapsed with multiple courses and pages snapshot 1`] = `
@@ -39,7 +39,7 @@ exports[`CourseList snapshots collapsed with multiple courses and pages 1`] = ` key="baz" /> `; -exports[`CourseList snapshots with filters 1`] = ` +exports[`CourseList no courses snapshot 1`] = ` +
+
+

+ My Courses +

+
+ +
+
+
+
+`; + +exports[`CourseList no filters snapshot 1`] = ` +
+
+

+ My Courses +

+
+ +
+
+
+
+`; + +exports[`CourseList with filters snapshot 1`] = `
@@ -82,7 +130,7 @@ exports[`CourseList snapshots with filters 1`] = `
`; -exports[`CourseList snapshots with multiple courses and pages 1`] = ` +exports[`CourseList with multiple courses and pages snapshot 1`] = `
@@ -116,7 +164,7 @@ exports[`CourseList snapshots with multiple courses and pages 1`] = ` key="baz" />
`; - -exports[`CourseList snapshots with no filters 1`] = ` -
-
-

- My Courses -

-
- -
-
-
-
-`; diff --git a/src/containers/CourseList/index.jsx b/src/containers/CourseList/index.jsx index a5d357f..88480ef 100644 --- a/src/containers/CourseList/index.jsx +++ b/src/containers/CourseList/index.jsx @@ -3,11 +3,13 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Pagination } from '@edx/paragon'; +import { hooks as appHooks } from 'data/redux'; import { ActiveCourseFilters, CourseFilterControls, } from 'containers/CourseFilterControls'; import CourseCard from 'containers/CourseCard'; +import NoCoursesView from './NoCoursesView'; import { useCourseListData, useIsCollapsed } from './hooks'; @@ -17,6 +19,7 @@ import './index.scss'; export const CourseList = () => { const { formatMessage } = useIntl(); + const hasCourses = appHooks.useHasCourses(); const { filterOptions, setPageNumber, @@ -33,25 +36,32 @@ export const CourseList = () => {
- {showFilters && ( -
- -
- )} -
- {visibleList.map(({ cardId }) => ( - - ))} - {numPages > 1 && ( - + {hasCourses + ? ( + <> + {showFilters && ( +
+ +
+ )} +
+ {visibleList.map(({ cardId }) => ( + + ))} + {numPages > 1 && ( + + )} +
+ + ) : ( + )} -
); }; diff --git a/src/containers/CourseList/index.test.jsx b/src/containers/CourseList/index.test.jsx index cf34a06..e138622 100644 --- a/src/containers/CourseList/index.test.jsx +++ b/src/containers/CourseList/index.test.jsx @@ -1,7 +1,12 @@ import { shallow } from 'enzyme'; -import CourseList from '.'; +import { hooks as appHooks } from 'data/redux'; import { useCourseListData, useIsCollapsed } from './hooks'; +import CourseList from '.'; + +jest.mock('data/redux', () => ({ + hooks: { useHasCourses: jest.fn() }, +})); jest.mock('./hooks', () => ({ useCourseListData: jest.fn(), @@ -14,6 +19,8 @@ jest.mock('containers/CourseFilterControls', () => ({ CourseFilterControls: 'CourseFilterControls', })); +appHooks.useHasCourses.mockReturnValue(true); + describe('CourseList', () => { const defaultCourseListData = { filterOptions: {}, @@ -31,28 +38,39 @@ describe('CourseList', () => { return shallow(); }; - describe('snapshots', () => { - test('with no filters', () => { + describe('no courses', () => { + test('snapshot', () => { + appHooks.useHasCourses.mockReturnValue(true); const wrapper = createWrapper(); expect(wrapper).toMatchSnapshot(); }); - test('with filters', () => { + }); + describe('no filters', () => { + test('snapshot', () => { + const wrapper = createWrapper(); + expect(wrapper).toMatchSnapshot(); + }); + }); + describe('with filters', () => { + test('snapshot', () => { const wrapper = createWrapper({ - filterOptions: { - abitary: 'filter', - }, + filterOptions: { abitary: 'filter' }, showFilters: true, }); expect(wrapper).toMatchSnapshot(); }); - test('with multiple courses and pages', () => { + }); + describe('with multiple courses and pages', () => { + test('snapshot', () => { const wrapper = createWrapper({ visibleList: [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }], numPages: 3, }); expect(wrapper).toMatchSnapshot(); }); - test('collapsed with multiple courses and pages', () => { + }); + describe('collapsed with multiple courses and pages', () => { + test('snapshot', () => { useIsCollapsed.mockReturnValueOnce(true); const wrapper = createWrapper({ visibleList: [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }], diff --git a/src/containers/Dashboard/LoadedView.jsx b/src/containers/Dashboard/DashboardLayout.jsx similarity index 75% rename from src/containers/Dashboard/LoadedView.jsx rename to src/containers/Dashboard/DashboardLayout.jsx index 3663dae..a748477 100644 --- a/src/containers/Dashboard/LoadedView.jsx +++ b/src/containers/Dashboard/DashboardLayout.jsx @@ -1,8 +1,8 @@ import React from 'react'; +import PropTypes from 'prop-types'; + import { Container, Col, Row } from '@edx/paragon'; -import CourseList from 'containers/CourseList'; -import WidgetSidebar from 'containers/WidgetSidebar'; import hooks from './hooks'; export const columnConfig = { @@ -20,22 +20,26 @@ export const columnConfig = { }, }; -export const LoadedView = () => { +export const DashboardLayout = ({ children, sidebar }) => { const isCollapsed = hooks.useIsDashboardCollapsed(); return ( - + {children} {!isCollapsed && (

 

)} - + {sidebar}
); }; +DashboardLayout.propTypes = { + children: PropTypes.node.isRequired, + sidebar: PropTypes.node.isRequired, +}; -export default LoadedView; +export default DashboardLayout; diff --git a/src/containers/Dashboard/LoadedView.test.jsx b/src/containers/Dashboard/DashboardLayout.test.jsx similarity index 60% rename from src/containers/Dashboard/LoadedView.test.jsx rename to src/containers/Dashboard/DashboardLayout.test.jsx index 9d763d5..b1eb8e5 100644 --- a/src/containers/Dashboard/LoadedView.test.jsx +++ b/src/containers/Dashboard/DashboardLayout.test.jsx @@ -1,20 +1,22 @@ import { shallow } from 'enzyme'; import { Col, Row } from '@edx/paragon'; -import CourseList from 'containers/CourseList'; -import WidgetSidebar from 'containers/WidgetSidebar'; - import hooks from './hooks'; -import LoadedView, { columnConfig } from './LoadedView'; +import DashboardLayout, { columnConfig } from './DashboardLayout'; jest.mock('./hooks', () => ({ useIsDashboardCollapsed: jest.fn(() => true), })); -describe('LoadedView', () => { +describe('DashboardLayout', () => { + const children = 'test-children'; + const props = { + sidebar: 'test-sidebar-content', + }; + const render = () => shallow({children}); const testColumns = () => { it('loads courseList and sidebar column layout', () => { - const columns = shallow().find(Row).find(Col); + const columns = render().find(Row).find(Col); Object.keys(columnConfig.courseList).forEach(size => { expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList[size]); }); @@ -22,25 +24,25 @@ describe('LoadedView', () => { expect(columns.at(1).props()[size]).toEqual(columnConfig.sidebar[size]); }); }); - it('displays CourseList in first column', () => { - const columns = shallow().find(Row).find(Col); - expect(columns.at(0).find(CourseList).length).toEqual(1); + it('displays children in first column', () => { + const columns = render().find(Row).find(Col); + expect(columns.at(0).contains(children)).toEqual(true); }); - it('displays WidgetSidebar in second column', () => { - const columns = shallow().find(Row).find(Col); - expect(columns.at(1).find(WidgetSidebar).length).toEqual(1); + it('displays sidebar prop in second column', () => { + const columns = render().find(Row).find(Col); + expect(columns.at(1).contains(props.sidebar)).toEqual(true); }); }; const testSnapshot = () => { test('snapshot', () => { - expect(shallow()).toMatchSnapshot(); + expect(render()).toMatchSnapshot(); }); }; describe('collapsed', () => { testColumns(); testSnapshot(); it('does not show spacer component above widget sidebar', () => { - const columns = shallow().find(Col); + const columns = render().find(Col); expect(columns.at(1).find('h2').length).toEqual(0); }); }); @@ -50,7 +52,7 @@ describe('LoadedView', () => { testColumns(); testSnapshot(); it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => { - const columns = shallow().find(Col); + const columns = render().find(Col); // nonbreaking space equivalent expect(columns.at(1).find('h2').text()).toEqual('\xA0'); }); diff --git a/src/containers/Dashboard/__snapshots__/LoadedView.test.jsx.snap b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap similarity index 90% rename from src/containers/Dashboard/__snapshots__/LoadedView.test.jsx.snap rename to src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap index df69a87..71408b7 100644 --- a/src/containers/Dashboard/__snapshots__/LoadedView.test.jsx.snap +++ b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LoadedView collapsed snapshot 1`] = ` +exports[`DashboardLayout collapsed snapshot 1`] = ` - + test-children - + test-sidebar-content `; -exports[`LoadedView not collapsed snapshot 1`] = ` +exports[`DashboardLayout not collapsed snapshot 1`] = ` - + test-children   - + test-sidebar-content diff --git a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap index 7e297c7..6e673bd 100644 --- a/src/containers/Dashboard/__snapshots__/index.test.jsx.snap +++ b/src/containers/Dashboard/__snapshots__/index.test.jsx.snap @@ -2,7 +2,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no available dashboards snapshot 1`] = `

- + } + > + +

`; exports[`Dashboard snapshots courses still loading snapshot 1`] = `

- + } + > + +

`; diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx index dd8bc49..629c964 100644 --- a/src/containers/Dashboard/index.jsx +++ b/src/containers/Dashboard/index.jsx @@ -2,14 +2,16 @@ import React from 'react'; import { hooks as appHooks } from 'data/redux'; import { RequestKeys } from 'data/constants/requests'; -import EmptyCourse from 'containers/EmptyCourse'; import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal'; import SelectSessionModal from 'containers/SelectSessionModal'; +import CourseList from 'containers/CourseList'; + +import LoadedSidebar from 'containers/WidgetContainers/LoadedSidebar'; +import NoCoursesSidebar from 'containers/WidgetContainers/NoCoursesSidebar'; import LoadingView from './LoadingView'; -import LoadedView from './LoadedView'; +import DashboardLayout from './DashboardLayout'; import hooks from './hooks'; - import './index.scss'; export const Dashboard = () => { @@ -20,7 +22,7 @@ export const Dashboard = () => { const initIsPending = appHooks.useRequestIsPending(RequestKeys.initialize); const showSelectSessionModal = appHooks.useShowSelectSessionModal(); return ( -
+

{pageTitle}

{!initIsPending && ( <> @@ -29,9 +31,13 @@ export const Dashboard = () => { )}
- {initIsPending && ()} - {(!initIsPending && hasCourses) && ()} - {(!initIsPending && !hasCourses) && ()} + {initIsPending + ? () + : ( + : }> + + + )}
); diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx index dd73ef8..5e6bbd8 100644 --- a/src/containers/Dashboard/index.test.jsx +++ b/src/containers/Dashboard/index.test.jsx @@ -2,11 +2,14 @@ import { shallow } from 'enzyme'; import { hooks as appHooks } from 'data/redux'; -import EmptyCourse from 'containers/EmptyCourse'; import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal'; import SelectSessionModal from 'containers/SelectSessionModal'; +import CourseList from 'containers/CourseList'; -import LoadedView from './LoadedView'; +import LoadedWidgetSidebar from 'containers/WidgetContainers/LoadedSidebar'; +import NoCoursesWidgetSidebar from 'containers/WidgetContainers/NoCoursesSidebar'; + +import DashboardLayout from './DashboardLayout'; import LoadingView from './LoadingView'; import hooks from './hooks'; import Dashboard from '.'; @@ -25,10 +28,12 @@ jest.mock('data/redux', () => ({ }, })); -jest.mock('containers/EmptyCourse', () => 'EmptyCourse'); jest.mock('containers/EnterpriseDashboardModal', () => 'EnterpriseDashboardModal'); +jest.mock('containers/CourseList', () => 'CourseList'); +jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'LoadedWidgetSidebar'); +jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'NoCoursesWidgetSidebar'); jest.mock('./LoadingView', () => 'LoadingView'); -jest.mock('./LoadedView', () => 'LoadedView'); +jest.mock('./DashboardLayout', () => 'DashboardLayout'); jest.mock('./hooks', () => ({ useInitializeDashboard: jest.fn(), @@ -115,7 +120,9 @@ describe('Dashboard', () => { initIsPending: false, showSelectSessionModal: true, }, - content: ['LoadedView', ], + content: ['LoadedView', ( + }> + )], showEnterpriseModal: false, showSelectSessionModal: true, }); @@ -129,7 +136,9 @@ describe('Dashboard', () => { initIsPending: false, showSelectSessionModal: false, }, - content: ['EmptyCourse', ], + content: ['Dashboard layout with no courses sidebar and content', ( + }> + )], showEnterpriseModal: true, showSelectSessionModal: false, }); diff --git a/src/containers/Dashboard/messages.js b/src/containers/Dashboard/messages.js deleted file mode 100644 index ec73e1c..0000000 --- a/src/containers/Dashboard/messages.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - myCourses: { - id: 'dashboard.mycourses', - defaultMessage: 'My Courses', - description: 'Course list heading', - }, -}); - -export default messages; diff --git a/src/containers/EmptyCourse/SuggestedCourses/__snapshots__/index.test.jsx.snap b/src/containers/EmptyCourse/SuggestedCourses/__snapshots__/index.test.jsx.snap deleted file mode 100644 index 62d818c..0000000 --- a/src/containers/EmptyCourse/SuggestedCourses/__snapshots__/index.test.jsx.snap +++ /dev/null @@ -1,94 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SuggestedCourses snapshot has 3 suggested courses 1`] = ` - -

- Some courses you may be interested in -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-`; - -exports[`SuggestedCourses snapshot no suggested courses 1`] = `""`; diff --git a/src/containers/EmptyCourse/SuggestedCourses/index.jsx b/src/containers/EmptyCourse/SuggestedCourses/index.jsx deleted file mode 100644 index 04f9a5f..0000000 --- a/src/containers/EmptyCourse/SuggestedCourses/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; - -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - ActionRow, - Card, - Button, - Container, -} from '@edx/paragon'; -import { hooks as appHooks } from 'data/redux'; - -import messages from './messages'; - -export const SuggestedCourses = () => { - const suggestedCourses = appHooks.useSuggestedCoursesData(); - const { formatMessage } = useIntl(); - if (!suggestedCourses.length) { return null; } - return ( - -

{formatMessage(messages.header)}

-
- {suggestedCourses.map((course) => ( - - - - - - - - - - - ))} -
-
- ); -}; - -export default SuggestedCourses; diff --git a/src/containers/EmptyCourse/SuggestedCourses/index.test.jsx b/src/containers/EmptyCourse/SuggestedCourses/index.test.jsx deleted file mode 100644 index c84930c..0000000 --- a/src/containers/EmptyCourse/SuggestedCourses/index.test.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { hooks as appHooks } from 'data/redux'; -import SuggestedCourses from '.'; - -jest.mock('data/redux', () => ({ - hooks: { - useSuggestedCoursesData: jest.fn(), - }, -})); - -const suggestedCourses = [ - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 1', - courseUrl: 'www.edx/suggested-course', - }, - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 2', - courseUrl: 'www.edx/suggested-course', - }, - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 3', - courseUrl: 'www.edx/suggested-course', - }, -]; - -describe('SuggestedCourses snapshot', () => { - test('no suggested courses', () => { - appHooks.useSuggestedCoursesData.mockReturnValueOnce([]); - expect(shallow()).toMatchSnapshot(); - }); - - test('has 3 suggested courses', () => { - appHooks.useSuggestedCoursesData.mockReturnValueOnce(suggestedCourses); - expect(shallow()).toMatchSnapshot(); - }); -}); diff --git a/src/containers/EmptyCourse/SuggestedCourses/messages.js b/src/containers/EmptyCourse/SuggestedCourses/messages.js deleted file mode 100644 index 80efcc6..0000000 --- a/src/containers/EmptyCourse/SuggestedCourses/messages.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - header: { - id: 'leanerDashboard.emptyCourse.suggestedCourses.header', - defaultMessage: 'Some courses you may be interested in', - description: 'Header for suggesting courses', - }, - viewCourseButton: { - id: 'leanerDashboard.emptyCourse.suggestedCourses.viewCourseButton', - defaultMessage: 'View Course', - description: 'Actions button to visit the suggested course', - }, - courseImageAlt: { - id: 'leanerDashboard.emptyCourse.suggestedCourses.courseImageAlt', - defaultMessage: 'Course image banner', - description: 'alt for course image', - }, -}); - -export default messages; diff --git a/src/containers/EmptyCourse/__snapshots__/index.test.jsx.snap b/src/containers/EmptyCourse/__snapshots__/index.test.jsx.snap deleted file mode 100644 index a18bc11..0000000 --- a/src/containers/EmptyCourse/__snapshots__/index.test.jsx.snap +++ /dev/null @@ -1,40 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyCourse snapshot 1`] = ` -
-
- empty course banner -

- -

-

- -

- -
- -
-`; diff --git a/src/containers/EmptyCourse/index.jsx b/src/containers/EmptyCourse/index.jsx deleted file mode 100644 index bb352e6..0000000 --- a/src/containers/EmptyCourse/index.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { - Button, - Image, -} from '@edx/paragon'; - -import emptyCourseSVG from 'assets/empty-course.svg'; - -import SuggestedCourses from './SuggestedCourses'; -import messages from './messages'; -import './index.scss'; - -export const EmptyCourse = () => ( -
-
- empty course banner -

- -

-

- -

- -
- -
-); - -export default EmptyCourse; diff --git a/src/containers/EmptyCourse/index.test.jsx b/src/containers/EmptyCourse/index.test.jsx deleted file mode 100644 index e83ecfd..0000000 --- a/src/containers/EmptyCourse/index.test.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import EmptyCourse from '.'; - -jest.mock('./SuggestedCourses', () => 'SuggestedCourses'); - -describe('EmptyCourse', () => { - test('snapshot', () => { - expect(shallow()).toMatchSnapshot(); - }); -}); diff --git a/src/containers/WidgetSidebar/__snapshots__/index.test.jsx.snap b/src/containers/WidgetContainers/LoadedSidebar/__snapshots__/index.test.jsx.snap similarity index 84% rename from src/containers/WidgetSidebar/__snapshots__/index.test.jsx.snap rename to src/containers/WidgetContainers/LoadedSidebar/__snapshots__/index.test.jsx.snap index d7a5c5a..e93114e 100644 --- a/src/containers/WidgetSidebar/__snapshots__/index.test.jsx.snap +++ b/src/containers/WidgetContainers/LoadedSidebar/__snapshots__/index.test.jsx.snap @@ -7,7 +7,7 @@ exports[`WidgetSidebar snapshots default 1`] = `
- +
`; diff --git a/src/containers/WidgetSidebar/index.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.jsx similarity index 61% rename from src/containers/WidgetSidebar/index.jsx rename to src/containers/WidgetContainers/LoadedSidebar/index.jsx index 619f951..1ebe8c1 100644 --- a/src/containers/WidgetSidebar/index.jsx +++ b/src/containers/WidgetContainers/LoadedSidebar/index.jsx @@ -1,11 +1,11 @@ import React from 'react'; -import LookingForChallengeWidget from './widgets/LookingForChallengeWidget'; +import RecommendationsPanel from 'widgets/RecommendationsPanel'; export const WidgetSidebar = () => (
- +
); diff --git a/src/containers/WidgetSidebar/index.test.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx similarity index 76% rename from src/containers/WidgetSidebar/index.test.jsx rename to src/containers/WidgetContainers/LoadedSidebar/index.test.jsx index bd8bb8e..68daf6f 100644 --- a/src/containers/WidgetSidebar/index.test.jsx +++ b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx @@ -2,7 +2,7 @@ import { shallow } from 'enzyme'; import WidgetSidebar from '.'; -jest.mock('./widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); +jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); describe('WidgetSidebar', () => { describe('snapshots', () => { diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/__snapshots__/index.test.jsx.snap b/src/containers/WidgetContainers/NoCoursesSidebar/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..29e9ea0 --- /dev/null +++ b/src/containers/WidgetContainers/NoCoursesSidebar/__snapshots__/index.test.jsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WidgetSidebar snapshots default 1`] = ` +
+
+ +
+
+`; diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx new file mode 100644 index 0000000..6380e21 --- /dev/null +++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import RecommendationsPanel from 'widgets/RecommendationsPanel'; + +export const WidgetSidebar = () => ( +
+
+ +
+
+); + +export default WidgetSidebar; diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx new file mode 100644 index 0000000..68daf6f --- /dev/null +++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx @@ -0,0 +1,14 @@ +import { shallow } from 'enzyme'; + +import WidgetSidebar from '.'; + +jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); + +describe('WidgetSidebar', () => { + describe('snapshots', () => { + test('default', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js index 53a30c7..d20142a 100644 --- a/src/data/constants/requests.js +++ b/src/data/constants/requests.js @@ -18,6 +18,7 @@ export const RequestKeys = StrictDict({ enrollEntitlementSession: 'enrollEntitlementSession', leaveEntitlementSession: 'leaveEntitlementSession', masquerade: 'masquerade', + recommendedCourses: 'recommendedCourses', }); export const ErrorCodes = StrictDict({ diff --git a/src/data/redux/app/selectors/currentList.js b/src/data/redux/app/selectors/currentList.js index dbcbd6b..1f4cc4a 100644 --- a/src/data/redux/app/selectors/currentList.js +++ b/src/data/redux/app/selectors/currentList.js @@ -12,7 +12,7 @@ export const sortFn = (transform, { reverse }) => (v1, v2) => { export const courseFilters = StrictDict({ [FilterKeys.notEnrolled]: (course) => !course.enrollment.isEnrolled, - [FilterKeys.done]: (course) => course.courseRun.isArchived, + [FilterKeys.done]: (course) => course.courseRun !== null && course.courseRun.isArchived, [FilterKeys.upgraded]: (course) => course.enrollment.isVerified, [FilterKeys.inProgress]: (course) => course.enrollment.hasStarted, [FilterKeys.notStarted]: (course) => !course.enrollment.hasStarted, diff --git a/src/data/redux/app/selectors/currentList.test.js b/src/data/redux/app/selectors/currentList.test.js index 4ba714e..efbaa2d 100644 --- a/src/data/redux/app/selectors/currentList.test.js +++ b/src/data/redux/app/selectors/currentList.test.js @@ -47,6 +47,7 @@ describe('courseList selector module', () => { }); test('done returns true iff learner has finished course', () => { filterFn = courseFilters[FilterKeys.done]; + expect(filterFn({ courseRun: null })).toEqual(false); expect(filterFn({ courseRun: { isArchived: true } })).toEqual(true); expect(filterFn({ courseRun: { isArchived: false } })).toEqual(false); }); diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js index 72bafd1..fb50ca9 100644 --- a/src/data/redux/hooks.js +++ b/src/data/redux/hooks.js @@ -11,8 +11,6 @@ export const usePageNumber = () => useSelector(appSelectors.pageNumber); export const useEmailConfirmationData = () => useSelector(appSelectors.emailConfirmation); export const useEnterpriseDashboardData = () => useSelector(appSelectors.enterpriseDashboard); export const usePlatformSettingsData = () => useSelector(appSelectors.platformSettings); -// suggested courses is max at 3 at the moment. -export const useSuggestedCoursesData = () => useSelector(appSelectors.suggestedCourses).slice(0, 3); export const useSelectSessionModalData = () => useSelector(appSelectors.selectSessionModal); export const useSocialSettingsData = () => useSelector(appSelectors.socialSettingsData); diff --git a/src/data/redux/thunkActions/app.test.js b/src/data/redux/thunkActions/app.test.js index 6bfd414..e43c37d 100644 --- a/src/data/redux/thunkActions/app.test.js +++ b/src/data/redux/thunkActions/app.test.js @@ -18,6 +18,7 @@ jest.mock('data/redux', () => ({ setPageNumber: jest.fn(v => ({ setPageNumber: v })), loadGlobalData: jest.fn(v => ({ loadGlobalData: v })), loadCourses: jest.fn(v => ({ loadCourses: v })), + loadRecommendedCourses: jest.fn(v => ({ loadRecommendedCourses: v })), }, }, selectors: { diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index b6fd274..5976748 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -38,6 +38,9 @@ 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('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), useIntl: () => ({ diff --git a/src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap b/src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap similarity index 100% rename from src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap rename to src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap diff --git a/src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.jsx b/src/widgets/LookingForChallengeWidget/index.jsx similarity index 100% rename from src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.jsx rename to src/widgets/LookingForChallengeWidget/index.jsx diff --git a/src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.scss b/src/widgets/LookingForChallengeWidget/index.scss similarity index 100% rename from src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.scss rename to src/widgets/LookingForChallengeWidget/index.scss diff --git a/src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.test.jsx b/src/widgets/LookingForChallengeWidget/index.test.jsx similarity index 100% rename from src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/index.test.jsx rename to src/widgets/LookingForChallengeWidget/index.test.jsx diff --git a/src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/messages.js b/src/widgets/LookingForChallengeWidget/messages.js similarity index 100% rename from src/containers/WidgetSidebar/widgets/LookingForChallengeWidget/messages.js rename to src/widgets/LookingForChallengeWidget/messages.js diff --git a/src/widgets/RecommendationsPanel/LoadedView.jsx b/src/widgets/RecommendationsPanel/LoadedView.jsx new file mode 100644 index 0000000..463db1f --- /dev/null +++ b/src/widgets/RecommendationsPanel/LoadedView.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; +import { Search } from '@edx/paragon/icons'; + +import { hooks } from 'data/redux'; +import CourseCard from './components/CourseCard'; +import messages from './messages'; + +import './index.scss'; + +export const LoadedView = ({ + courses, +}) => { + const { courseSearchUrl } = hooks.usePlatformSettingsData(); + const { formatMessage } = useIntl(); + + return ( +
+

{formatMessage(messages.recommendationsHeading)}

+
+ {courses.map((course) => ( + + ))} +
+
+ +
+
+ ); +}; + +LoadedView.propTypes = { + courses: PropTypes.arrayOf(PropTypes.shape({ + courseKey: PropTypes.string, + title: PropTypes.string, + logoImageUrl: PropTypes.string, + marketingUrl: PropTypes.string, + })).isRequired, +}; + +export default LoadedView; diff --git a/src/widgets/RecommendationsPanel/LoadedView.test.jsx b/src/widgets/RecommendationsPanel/LoadedView.test.jsx new file mode 100644 index 0000000..5606ef0 --- /dev/null +++ b/src/widgets/RecommendationsPanel/LoadedView.test.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { hooks } from 'data/redux'; +import LoadedView from './LoadedView'; +import mockData from './mockData'; + +jest.mock('./components/CourseCard', () => 'CourseCard'); +jest.mock('data/redux', () => ({ + hooks: { + usePlatformSettingsData: jest.fn(), + }, +})); + +const courseSearchUrl = 'test-course-search-url'; +hooks.usePlatformSettingsData.mockReturnValue(courseSearchUrl); + +describe('RecommendationsPanel LoadedView', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/src/widgets/RecommendationsPanel/LoadingView.jsx b/src/widgets/RecommendationsPanel/LoadingView.jsx new file mode 100644 index 0000000..8af8402 --- /dev/null +++ b/src/widgets/RecommendationsPanel/LoadingView.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Spinner } from '@edx/paragon'; + +import { useDashboardMessages } from 'containers/Dashboard/hooks'; + +export const LoadingView = () => { + const { spinnerScreenReaderText } = useDashboardMessages(); + return ( +
+ +
+ ); +}; + +export default LoadingView; diff --git a/src/widgets/RecommendationsPanel/LoadingView.test.jsx b/src/widgets/RecommendationsPanel/LoadingView.test.jsx new file mode 100644 index 0000000..1b68e3a --- /dev/null +++ b/src/widgets/RecommendationsPanel/LoadingView.test.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { useDashboardMessages } from 'containers/Dashboard/hooks'; +import LoadingView from './LoadingView'; + +jest.mock('./components/CourseCard', () => 'CourseCard'); +jest.mock('containers/Dashboard/hooks', () => ({ + useDashboardMessages: jest.fn(), +})); + +const spinnerScreenReaderText = 'test-spinner-screen-reader-text'; +useDashboardMessages.mockReturnValue(spinnerScreenReaderText); + +describe('RecommendationsPanel LoadingView', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap b/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap new file mode 100644 index 0000000..5469b2a --- /dev/null +++ b/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RecommendationsPanel LoadedView snapshot 1`] = ` +
+

+ Recommendations for you +

+
+ + + + +
+
+ +
+
+`; diff --git a/src/widgets/RecommendationsPanel/__snapshots__/LoadingView.test.jsx.snap b/src/widgets/RecommendationsPanel/__snapshots__/LoadingView.test.jsx.snap new file mode 100644 index 0000000..1f52960 --- /dev/null +++ b/src/widgets/RecommendationsPanel/__snapshots__/LoadingView.test.jsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RecommendationsPanel LoadingView snapshot 1`] = ` +
+ +
+`; diff --git a/src/widgets/RecommendationsPanel/api.js b/src/widgets/RecommendationsPanel/api.js new file mode 100644 index 0000000..0446af3 --- /dev/null +++ b/src/widgets/RecommendationsPanel/api.js @@ -0,0 +1,12 @@ +import { StrictDict } from 'utils'; +import { get, stringifyUrl } from 'data/services/lms/utils'; +import urls from 'data/services/lms/urls'; + +export const fetchUrl = `${urls.api}/learner_home/recommendation/courses/`; +export const apiKeys = StrictDict({ user: 'user' }); + +const fetchRecommendedCourses = () => get(stringifyUrl(fetchUrl)); + +export default { + fetchRecommendedCourses, +}; diff --git a/src/widgets/RecommendationsPanel/api.test.js b/src/widgets/RecommendationsPanel/api.test.js new file mode 100644 index 0000000..4932906 --- /dev/null +++ b/src/widgets/RecommendationsPanel/api.test.js @@ -0,0 +1,17 @@ +import { get, stringifyUrl } from 'data/services/lms/utils'; +import api, { fetchUrl } from './api'; + +jest.mock('data/services/lms/utils', () => ({ + stringifyUrl: (...args) => ({ stringifyUrl: args }), + get: (...args) => ({ get: args }), +})); + +describe('recommendedCourses api', () => { + describe('fetchRecommendedCourses', () => { + it('calls get with the correct recommendation courses URL and user', () => { + expect(api.fetchRecommendedCourses()).toEqual( + get(stringifyUrl(fetchUrl)), + ); + }); + }); +}); diff --git a/src/widgets/RecommendationsPanel/components/CourseCard.jsx b/src/widgets/RecommendationsPanel/components/CourseCard.jsx new file mode 100644 index 0000000..6604b6e --- /dev/null +++ b/src/widgets/RecommendationsPanel/components/CourseCard.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Card, Hyperlink, Truncate } from '@edx/paragon'; + +import { useIsCollapsed } from 'containers/CourseCard/hooks'; +import './index.scss'; + +export const CourseCard = ({ course }) => { + const isCollapsed = useIsCollapsed(); + return ( + + +
+ + + +

+ + {course.title} + +

+
+
+
+
+
+ ); +}; + +CourseCard.propTypes = { + course: PropTypes.shape({ + courseKey: PropTypes.string, + title: PropTypes.string, + logoImageUrl: PropTypes.string, + marketingUrl: PropTypes.string, + }).isRequired, +}; + +export default CourseCard; diff --git a/src/widgets/RecommendationsPanel/components/index.scss b/src/widgets/RecommendationsPanel/components/index.scss new file mode 100644 index 0000000..ae74d9a --- /dev/null +++ b/src/widgets/RecommendationsPanel/components/index.scss @@ -0,0 +1,33 @@ +@import "@edx/paragon/scss/core/core"; + +.card-link{ + display: block !important; + margin: 0.5rem 0 0.5rem 0 !important; +} + +.recommended-course-card { + margin: 0.5rem 0 0.5rem 0 !important; + + .pgn__card-wrapper-image-cap { + width: 7.188rem !important; + max-width: 7.188rem !important; + min-width: 7.188rem !important; + height: 4.125rem !important; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15), 0 1px 4px rgba(0, 0, 0, 0.15); + border-radius: 4px; + padding: 0.5rem; + + .pgn__card-image-cap { + max-width: 100% !important; + max-height: 100% !important; + } + } + .pgn__card-section { + padding: 0; + } + margin-top: 0.313rem; +} + +.text-info-500 { + margin: 0 !important; +} diff --git a/src/widgets/RecommendationsPanel/hooks.js b/src/widgets/RecommendationsPanel/hooks.js new file mode 100644 index 0000000..94813cc --- /dev/null +++ b/src/widgets/RecommendationsPanel/hooks.js @@ -0,0 +1,47 @@ +import React from 'react'; + +import { StrictDict } from 'utils'; +import { RequestStates } from 'data/constants/requests'; + +import * as module from './hooks'; +import api from './api'; + +export const state = StrictDict({ + requestState: (val) => React.useState(val), // eslint-disable-line + data: (val) => React.useState(val), // eslint-disable-line +}); + +export const useFetchCourses = (setRequestState, setData) => { + React.useEffect(() => { + let isMounted = true; + api.fetchRecommendedCourses().then((response) => { + if (isMounted) { + setRequestState(RequestStates.completed); + setData(response); + } + }).catch(() => { + if (isMounted) { + setRequestState(RequestStates.failed); + } + }); + return () => { isMounted = false; }; + }); +}; + +export const useRecommendationPanelData = () => { + const [requestState, setRequestState] = module.state.requestState(RequestStates.pending); + const [data, setData] = module.state.data({}); + module.useFetchCourses(setRequestState, setData); + const courses = data.data?.courses || []; + return { + courses, + isLoaded: requestState === RequestStates.completed && courses.length > 0, + isFailed: requestState === RequestStates.failed + || (requestState === RequestStates.completed && courses.length === 0), + isLoading: requestState === RequestStates.pending, + }; +}; + +export default { + useRecommendationPanelData, +}; diff --git a/src/widgets/RecommendationsPanel/hooks.test.js b/src/widgets/RecommendationsPanel/hooks.test.js new file mode 100644 index 0000000..0b58d3b --- /dev/null +++ b/src/widgets/RecommendationsPanel/hooks.test.js @@ -0,0 +1,178 @@ +import React from 'react'; + +import { MockUseState } from 'testUtils'; +import { RequestStates } from 'data/constants/requests'; + +import api from './api'; +import * as hooks from './hooks'; + +jest.mock('./api', () => ({ + fetchRecommendedCourses: jest.fn(), +})); + +const state = new MockUseState(hooks); + +const testList = [1, 2, 3]; +let out; +describe('RecommendationsPanel hooks', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + describe('state fields', () => { + state.testGetter(state.keys.requestState); + }); + describe('useFetchCourse', () => { + describe('behavior', () => { + describe('useEffect call', () => { + let calls; + let cb; + let prereqs; + const response = 'test-response'; + const setRequestState = jest.fn(); + const setData = jest.fn(); + beforeEach(() => { + out = hooks.useFetchCourses(setRequestState, setData); + ({ calls } = React.useEffect.mock); + ([[cb, prereqs]] = calls); + }); + it('calls useEffect once', () => { + expect(calls.length).toEqual(1); + }); + it('it is only run once (no prereqs)', () => { + expect(prereqs).toEqual(undefined); + }); + it('calls fetchRecommendedCourses', () => { + api.fetchRecommendedCourses.mockReturnValueOnce(Promise.resolve(response)); + cb(); + expect(api.fetchRecommendedCourses).toHaveBeenCalledWith(); + }); + describe('successful fetch on mounted component', () => { + it('sets request state to completed and loads response', async () => { + let resolveFn; + api.fetchRecommendedCourses.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + cb(); + expect(api.fetchRecommendedCourses).toHaveBeenCalledWith(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalledWith(response); + await resolveFn(response); + expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed); + expect(setData).toHaveBeenCalledWith(response); + }); + }); + describe('successful fetch on unmounted component', () => { + it('it does nothing', async () => { + let resolveFn; + api.fetchRecommendedCourses.mockReturnValueOnce(new Promise(resolve => { + resolveFn = resolve; + })); + const unMount = cb(); + expect(api.fetchRecommendedCourses).toHaveBeenCalledWith(); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalledWith(response); + unMount(); + await resolveFn(response); + expect(setRequestState).not.toHaveBeenCalled(); + expect(setData).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + describe('useRecommendationPanelData', () => { + let fetchSpy; + beforeEach(() => { + state.mock(); + fetchSpy = jest.spyOn(hooks, 'useFetchCourses').mockImplementationOnce(() => {}); + out = hooks.useRecommendationPanelData(); + }); + it('calls useFetchCourses with setRequestState and setData', () => { + expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data); + }); + it('initializes requestState as RequestStates.pending', () => { + state.expectInitializedWith(state.keys.requestState, RequestStates.pending); + }); + it('initializes requestState as RequestStates.pending', () => { + state.expectInitializedWith(state.keys.requestState, RequestStates.pending); + }); + describe('output', () => { + describe('request is completed, with returned courses', () => { + beforeEach(() => { + state.mockVal(state.keys.requestState, RequestStates.completed); + state.mockVal(state.keys.data, { data: { courses: testList } }); + out = hooks.useRecommendationPanelData(); + }); + it('is not loading', () => { + expect(out.isLoading).toEqual(false); + }); + it('is loaded', () => { + expect(out.isLoaded).toEqual(true); + }); + it('is not failed', () => { + expect(out.isFailed).toEqual(false); + }); + it('returns passed courses list', () => { + expect(out.courses).toEqual(testList); + }); + }); + describe('request is completed, with no returned courses', () => { + beforeEach(() => { + state.mockVal(state.keys.requestState, RequestStates.completed); + state.mockVal(state.keys.data, { data: { courses: [] } }); + out = hooks.useRecommendationPanelData(); + }); + it('is not loading', () => { + expect(out.isLoading).toEqual(false); + }); + it('is not loaded', () => { + expect(out.isLoaded).toEqual(false); + }); + it('is failed', () => { + expect(out.isFailed).toEqual(true); + }); + it('returns empty courses list', () => { + expect(out.courses).toEqual([]); + }); + }); + describe('request is failed', () => { + beforeEach(() => { + state.mockVal(state.keys.requestState, RequestStates.failed); + state.mockVal(state.keys.data, {}); + out = hooks.useRecommendationPanelData(); + }); + it('is not loading', () => { + expect(out.isLoading).toEqual(false); + }); + it('is not loaded', () => { + expect(out.isLoaded).toEqual(false); + }); + it('is failed', () => { + expect(out.isFailed).toEqual(true); + }); + it('returns empty courses list', () => { + expect(out.courses).toEqual([]); + }); + }); + describe('request is pending', () => { + beforeEach(() => { + state.mockVal(state.keys.requestState, RequestStates.pending); + state.mockVal(state.keys.data, {}); + out = hooks.useRecommendationPanelData(); + }); + it('is loading', () => { + expect(out.isLoading).toEqual(true); + }); + it('is not loaded', () => { + expect(out.isLoaded).toEqual(false); + }); + it('is not failed', () => { + expect(out.isFailed).toEqual(false); + }); + it('returns empty courses list', () => { + expect(out.courses).toEqual([]); + }); + }); + }); + }); +}); diff --git a/src/widgets/RecommendationsPanel/index.jsx b/src/widgets/RecommendationsPanel/index.jsx new file mode 100644 index 0000000..4725a8d --- /dev/null +++ b/src/widgets/RecommendationsPanel/index.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import LookingForChallengeWidget from 'widgets/LookingForChallengeWidget'; +import LoadingView from './LoadingView'; +import LoadedView from './LoadedView'; +import hooks from './hooks'; + +export const RecommendationsPanel = () => { + const { + courses, + isFailed, + isLoaded, + isLoading, + } = hooks.useRecommendationPanelData(); + + if (isLoading) { + return (); + } + if (isLoaded) { + return (); + } + if (isFailed) { + return (); + } + // default fallback + return (); +}; + +export default RecommendationsPanel; diff --git a/src/widgets/RecommendationsPanel/index.scss b/src/widgets/RecommendationsPanel/index.scss new file mode 100644 index 0000000..e4f6f5c --- /dev/null +++ b/src/widgets/RecommendationsPanel/index.scss @@ -0,0 +1,17 @@ +@import "@edx/paragon/scss/core/core"; + +.explore-courses-btn { + padding-top: 16px; +} + +.panel-background { + background: $light-200; +} + +.recommendations-loading { + display: flex; + justify-content: center; + align-items: center; + height: 7.813rem; + background: $light-200; +} diff --git a/src/widgets/RecommendationsPanel/index.test.jsx b/src/widgets/RecommendationsPanel/index.test.jsx new file mode 100644 index 0000000..3b0ff83 --- /dev/null +++ b/src/widgets/RecommendationsPanel/index.test.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import LookingForChallengeWidget from 'widgets/LookingForChallengeWidget'; +import hooks from './hooks'; +import mockData from './mockData'; +import LoadedView from './LoadedView'; +import LoadingView from './LoadingView'; +import RecommendationsPanel from '.'; + +jest.mock('./hooks', () => ({ + useRecommendationPanelData: jest.fn(), +})); +jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); +jest.mock('./LoadingView', () => 'LoadingView'); +jest.mock('./LoadedView', () => 'LoadedView'); + +const { courses } = mockData; + +describe('RecommendationsPanel snapshot', () => { + it('displays LoadingView if request is loading', () => { + hooks.useRecommendationPanelData.mockReturnValueOnce({ + courses: [], + isFailed: false, + isLoaded: false, + isLoading: true, + }); + expect(shallow()).toMatchObject(shallow()); + }); + it('displays LoadedView with courses if request is loaded', () => { + hooks.useRecommendationPanelData.mockReturnValueOnce({ + courses, + isFailed: false, + isLoaded: true, + isLoading: false, + }); + expect(shallow()).toMatchObject( + shallow(), + ); + }); + it('displays LookingForChallengeWidget if request is failed', () => { + hooks.useRecommendationPanelData.mockReturnValueOnce({ + courses: [], + isFailed: true, + isLoaded: false, + isLoading: false, + }); + expect(shallow()).toMatchObject( + shallow(), + ); + }); + it('defaults to LookingForChallengeWidget if no flags are true', () => { + hooks.useRecommendationPanelData.mockReturnValueOnce({ + courses: [], + isFailed: false, + isLoaded: false, + isLoading: false, + }); + expect(shallow()).toMatchObject( + shallow(), + ); + }); +}); diff --git a/src/widgets/RecommendationsPanel/messages.js b/src/widgets/RecommendationsPanel/messages.js new file mode 100644 index 0000000..a4ed773 --- /dev/null +++ b/src/widgets/RecommendationsPanel/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + recommendationsHeading: { + id: 'RecommendationsPanel.recommendationsHeading', + defaultMessage: 'Recommendations for you', + description: 'Heading on recommendations panel', + }, + exploreCoursesButton: { + id: 'RecommendationsPanel.exploreCoursesButton', + defaultMessage: 'Explore courses', + description: 'Button to explore more courses on recommendations panel', + }, +}); + +export default messages; diff --git a/src/widgets/RecommendationsPanel/mockData.js b/src/widgets/RecommendationsPanel/mockData.js new file mode 100644 index 0000000..20bef9e --- /dev/null +++ b/src/widgets/RecommendationsPanel/mockData.js @@ -0,0 +1,31 @@ +export const recommendedCoursesData = { + courses: [ + { + logoImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', + title: 'Recommended course 1', + marketingUrl: 'www.edx/recommended-course', + courseKey: 'cs-1', + }, + { + logoImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', + title: 'Recommended course 2 with a really really really long name for some reason', + marketingUrl: 'www.edx/recommended-course', + courseKey: 'cs-2', + }, + { + logoImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', + title: 'Recommended course 3', + marketingUrl: 'www.edx/recommended-course', + courseKey: 'cs-3', + }, + { + logoImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', + title: 'Recommended course 4', + marketingUrl: 'www.edx/recommended-course', + courseKey: 'cs-4', + }, + ], + isPersonalizedRecommendation: true, +}; + +export default recommendedCoursesData;