Bw/recommendations panel (#63)

Co-authored-by: Shafqat Farhan <shafqat.farhan@arbisoft.com>
This commit is contained in:
Ben Warzeski
2022-11-04 15:01:56 -04:00
committed by GitHub
parent b8245d6631
commit dde8d45df3
62 changed files with 1149 additions and 425 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -92,7 +92,7 @@ exports[`CertificateBanner snapshot is restricted and verified 1`] = `
exports[`CertificateBanner snapshot not passing and audit 1`] = `
<Banner>
Grade required to pass the course: 0.8
Grade required to pass the course: 0.8%
</Banner>
`;
@@ -114,6 +114,6 @@ exports[`CertificateBanner snapshot not passing and not audit and not finished 1
<Banner
variant="warning"
>
Grade required for a certificate: 0.8
Grade required for a certificate: 0.8%
</Banner>
`;

View File

@@ -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',

View File

@@ -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)}
</Button>

View File

@@ -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(<CourseFilterControls {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe('mobile', () => {
test('snapshot', () => {
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth - 1 });
const wrapper = shallow(<CourseFilterControls {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('is not mobile', () => {
});
describe('is not mobile', () => {
test('snapshot', () => {
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth });
const wrapper = shallow(<CourseFilterControls {...props} />);
expect(wrapper).toMatchSnapshot();

View File

@@ -1,10 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseFilterControls snapshot is mobile 1`] = `
exports[`CourseFilterControls is not mobile snapshot 1`] = `
<div
id="course-filter-controls"
>
<Button
disabled={false}
iconBefore={[MockFunction icons.Tune]}
onClick={[MockFunction open]}
variant="outline-primary"
>
Refine
</Button>
<Form>
<ModalPopup
isOpen={false}
onClose={[MockFunction close]}
placement="bottom-end"
positionRef="test-target"
>
<div
className="bg-white p-3 rounded shadow d-flex flex-row"
id="course-filter-controls-card"
>
<div
className="filter-form-col"
>
<FilterForm
filters={
Array [
"test-filter",
]
}
handleFilterChange={[MockFunction handleFilterChange]}
/>
</div>
<hr
className="h-100 bg-primary-200 mx-3 my-0"
/>
<div
className="filter-form-col text-left m-1"
>
<SortForm
handleSortChange={[MockFunction handleSortChange]}
sortBy="test-sort-by"
/>
</div>
</div>
</ModalPopup>
</Form>
</div>
`;
exports[`CourseFilterControls mobile snapshot 1`] = `
<div
id="course-filter-controls"
>
<Button
disabled={false}
iconBefore={[MockFunction icons.Tune]}
onClick={[MockFunction open]}
variant="outline-primary"
@@ -63,11 +116,12 @@ exports[`CourseFilterControls snapshot is mobile 1`] = `
</div>
`;
exports[`CourseFilterControls snapshot is not mobile 1`] = `
exports[`CourseFilterControls no courses snapshot 1`] = `
<div
id="course-filter-controls"
>
<Button
disabled={true}
iconBefore={[MockFunction icons.Tune]}
onClick={[MockFunction open]}
variant="outline-primary"

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NoCoursesView snapshot 1`] = `
<div
className="d-flex align-items-center justify-content-center mb-4.5"
id="no-courses-content-view"
>
<Image
alt="No Courses view banner"
src="icon/mock/path"
/>
<h1>
Looking for a new challenge?
</h1>
<p>
Explore our courses to add them to your dashboard.
</p>
<Button
as="a"
href="course-search-url"
variant="brand"
>
Explore courses
</Button>
</div>
`;

View File

@@ -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 (
<div
id="no-courses-content-view"
className="d-flex align-items-center justify-content-center mb-4.5"
>
<Image src={emptyCourseSVG} alt={formatMessage(messages.bannerAlt)} />
<h1>
{formatMessage(messages.lookingForChallengePrompt)}
</h1>
<p>
{formatMessage(messages.exploreCoursesPrompt)}
</p>
<Button
variant="brand"
as="a"
href={courseSearchUrl}
iconBefore={Search}
>
{formatMessage(messages.exploreCoursesButton)}
</Button>
</div>
);
};
export default NoCoursesView;

View File

@@ -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);
}
}
}

View File

@@ -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(<EmptyCourse />)).toMatchSnapshot();
});
});

View File

@@ -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;

View File

@@ -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`] = `
<div
className="course-list-container"
>
@@ -39,7 +39,7 @@ exports[`CourseList snapshots collapsed with multiple courses and pages 1`] = `
key="baz"
/>
<Pagination
className="mx-auto"
className="mx-auto mb-2"
onPageSelect={[MockFunction setPageNumber]}
pageCount={3}
paginationLabel="Course List"
@@ -49,7 +49,55 @@ exports[`CourseList snapshots collapsed with multiple courses and pages 1`] = `
</div>
`;
exports[`CourseList snapshots with filters 1`] = `
exports[`CourseList no courses snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<div
className="d-flex flex-column flex-grow-1"
/>
</div>
`;
exports[`CourseList no filters snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<div
className="d-flex flex-column flex-grow-1"
/>
</div>
`;
exports[`CourseList with filters snapshot 1`] = `
<div
className="course-list-container"
>
@@ -82,7 +130,7 @@ exports[`CourseList snapshots with filters 1`] = `
</div>
`;
exports[`CourseList snapshots with multiple courses and pages 1`] = `
exports[`CourseList with multiple courses and pages snapshot 1`] = `
<div
className="course-list-container"
>
@@ -116,7 +164,7 @@ exports[`CourseList snapshots with multiple courses and pages 1`] = `
key="baz"
/>
<Pagination
className="mx-auto"
className="mx-auto mb-2"
onPageSelect={[MockFunction setPageNumber]}
pageCount={3}
paginationLabel="Course List"
@@ -125,27 +173,3 @@ exports[`CourseList snapshots with multiple courses and pages 1`] = `
</div>
</div>
`;
exports[`CourseList snapshots with no filters 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<div
className="d-flex flex-column flex-grow-1"
/>
</div>
`;

View File

@@ -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 = () => {
<CourseFilterControls {...filterOptions} />
</div>
</div>
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} />
</div>
)}
<div className="d-flex flex-column flex-grow-1">
{visibleList.map(({ cardId }) => (
<CourseCard key={cardId} cardId={cardId} />
))}
{numPages > 1 && (
<Pagination
variant={isCollapsed ? 'reduced' : 'secondary'}
paginationLabel="Course List"
className="mx-auto"
pageCount={numPages}
onPageSelect={setPageNumber}
/>
{hasCourses
? (
<>
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} />
</div>
)}
<div className="d-flex flex-column flex-grow-1">
{visibleList.map(({ cardId }) => (
<CourseCard key={cardId} cardId={cardId} />
))}
{numPages > 1 && (
<Pagination
variant={isCollapsed ? 'reduced' : 'secondary'}
paginationLabel="Course List"
className="mx-auto mb-2"
pageCount={numPages}
onPageSelect={setPageNumber}
/>
)}
</div>
</>
) : (
<NoCoursesView />
)}
</div>
</div>
);
};

View File

@@ -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(<CourseList />);
};
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' }],

View File

@@ -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 (
<Container fluid size="xl">
<Row>
<Col {...columnConfig.courseList} className="course-list-column">
<CourseList />
{children}
</Col>
<Col {...columnConfig.sidebar} className="sidebar-column">
{!isCollapsed && (<h2 className="course-list-title">&nbsp;</h2>)}
<WidgetSidebar />
{sidebar}
</Col>
</Row>
</Container>
);
};
DashboardLayout.propTypes = {
children: PropTypes.node.isRequired,
sidebar: PropTypes.node.isRequired,
};
export default LoadedView;
export default DashboardLayout;

View File

@@ -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(<DashboardLayout sidebar={props.sidebar}>{children}</DashboardLayout>);
const testColumns = () => {
it('loads courseList and sidebar column layout', () => {
const columns = shallow(<LoadedView />).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(<LoadedView />).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(<LoadedView />).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(<LoadedView />)).toMatchSnapshot();
expect(render()).toMatchSnapshot();
});
};
describe('collapsed', () => {
testColumns();
testSnapshot();
it('does not show spacer component above widget sidebar', () => {
const columns = shallow(<LoadedView />).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(<LoadedView />).find(Col);
const columns = render().find(Col);
// nonbreaking space equivalent
expect(columns.at(1).find('h2').text()).toEqual('\xA0');
});

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoadedView collapsed snapshot 1`] = `
exports[`DashboardLayout collapsed snapshot 1`] = `
<Container
fluid={true}
size="xl"
@@ -33,7 +33,7 @@ exports[`LoadedView collapsed snapshot 1`] = `
}
}
>
<CourseList />
test-children
</Col>
<Col
className="sidebar-column"
@@ -62,13 +62,13 @@ exports[`LoadedView collapsed snapshot 1`] = `
}
}
>
<WidgetSidebar />
test-sidebar-content
</Col>
</Row>
</Container>
`;
exports[`LoadedView not collapsed snapshot 1`] = `
exports[`DashboardLayout not collapsed snapshot 1`] = `
<Container
fluid={true}
size="xl"
@@ -101,7 +101,7 @@ exports[`LoadedView not collapsed snapshot 1`] = `
}
}
>
<CourseList />
test-children
</Col>
<Col
className="sidebar-column"
@@ -135,7 +135,7 @@ exports[`LoadedView not collapsed snapshot 1`] = `
>
 
</h2>
<WidgetSidebar />
test-sidebar-content
</Col>
</Row>
</Container>

View File

@@ -2,7 +2,7 @@
exports[`Dashboard snapshots courses loaded, show select session modal, no available dashboards snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-3"
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
>
<h1
@@ -14,14 +14,18 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
<div
id="dashboard-content"
>
<LoadedView />
<DashboardLayout
sidebar={<LoadedWidgetSidebar />}
>
<CourseList />
</DashboardLayout>
</div>
</div>
`;
exports[`Dashboard snapshots courses still loading snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-3"
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
>
<h1
@@ -39,7 +43,7 @@ exports[`Dashboard snapshots courses still loading snapshot 1`] = `
exports[`Dashboard snapshots there are no courses, there ARE available dashboards snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-3"
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
>
<h1
@@ -51,7 +55,11 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
<div
id="dashboard-content"
>
<EmptyCourse />
<DashboardLayout
sidebar={<NoCoursesWidgetSidebar />}
>
<CourseList />
</DashboardLayout>
</div>
</div>
`;

View File

@@ -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 (
<div id="dashboard-container" className="d-flex flex-column p-2 pt-3">
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
<h1 className="sr-only">{pageTitle}</h1>
{!initIsPending && (
<>
@@ -29,9 +31,13 @@ export const Dashboard = () => {
</>
)}
<div id="dashboard-content">
{initIsPending && (<LoadingView />)}
{(!initIsPending && hasCourses) && (<LoadedView />)}
{(!initIsPending && !hasCourses) && (<EmptyCourse />)}
{initIsPending
? (<LoadingView />)
: (
<DashboardLayout sidebar={hasCourses ? <LoadedSidebar /> : <NoCoursesSidebar />}>
<CourseList />
</DashboardLayout>
)}
</div>
</div>
);

View File

@@ -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', <LoadedView />],
content: ['LoadedView', (
<DashboardLayout sidebar={<LoadedWidgetSidebar />}><CourseList /></DashboardLayout>
)],
showEnterpriseModal: false,
showSelectSessionModal: true,
});
@@ -129,7 +136,9 @@ describe('Dashboard', () => {
initIsPending: false,
showSelectSessionModal: false,
},
content: ['EmptyCourse', <EmptyCourse />],
content: ['Dashboard layout with no courses sidebar and content', (
<DashboardLayout sidebar={<NoCoursesWidgetSidebar />}><CourseList /></DashboardLayout>
)],
showEnterpriseModal: true,
showSelectSessionModal: false,
});

View File

@@ -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;

View File

@@ -1,94 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuggestedCourses snapshot has 3 suggested courses 1`] = `
<Container
size="md"
>
<h3
className="text-center"
>
Some courses you may be interested in
</h3>
<div
className="d-flex"
>
<Card
className="m-3"
key="Suggested course 1"
>
<Card.ImageCap
src="https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg"
srcAlt="Course image banner"
/>
<Card.Header
className="mb-2"
title="Suggested course 1"
/>
<Card.Body
className="h-100"
/>
<Card.Footer>
<ActionRow>
<Button
as="a"
>
View Course
</Button>
</ActionRow>
</Card.Footer>
</Card>
<Card
className="m-3"
key="Suggested course 2"
>
<Card.ImageCap
src="https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg"
srcAlt="Course image banner"
/>
<Card.Header
className="mb-2"
title="Suggested course 2"
/>
<Card.Body
className="h-100"
/>
<Card.Footer>
<ActionRow>
<Button
as="a"
>
View Course
</Button>
</ActionRow>
</Card.Footer>
</Card>
<Card
className="m-3"
key="Suggested course 3"
>
<Card.ImageCap
src="https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg"
srcAlt="Course image banner"
/>
<Card.Header
className="mb-2"
title="Suggested course 3"
/>
<Card.Body
className="h-100"
/>
<Card.Footer>
<ActionRow>
<Button
as="a"
>
View Course
</Button>
</ActionRow>
</Card.Footer>
</Card>
</div>
</Container>
`;
exports[`SuggestedCourses snapshot no suggested courses 1`] = `""`;

View File

@@ -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 (
<Container size="md">
<h3 className="text-center">{formatMessage(messages.header)}</h3>
<div className="d-flex">
{suggestedCourses.map((course) => (
<Card key={course.courseName} className="m-3">
<Card.ImageCap
src={course.bannerImgSrc}
srcAlt={formatMessage(messages.courseImageAlt)}
/>
<Card.Header title={course.courseName} className="mb-2" />
<Card.Body className="h-100" />
<Card.Footer>
<ActionRow>
<Button as="a">{formatMessage(messages.viewCourseButton)}</Button>
</ActionRow>
</Card.Footer>
</Card>
))}
</div>
</Container>
);
};
export default SuggestedCourses;

View File

@@ -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(<SuggestedCourses />)).toMatchSnapshot();
});
test('has 3 suggested courses', () => {
appHooks.useSuggestedCoursesData.mockReturnValueOnce(suggestedCourses);
expect(shallow(<SuggestedCourses />)).toMatchSnapshot();
});
});

View File

@@ -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;

View File

@@ -1,40 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmptyCourse snapshot 1`] = `
<div
className="p-3"
>
<div
className="empty-course-hero"
>
<Image
alt="empty course banner"
src="icon/mock/path"
/>
<h1>
<FormattedMessage
defaultMessage="Looking for a new challenge?"
description="Prompt user for new challenge"
id="EmptyCourse.lookingForChallengePrompt"
/>
</h1>
<p>
<FormattedMessage
defaultMessage="Explore our courses to add them to your dashboard."
description="Prompt user to explore more courses"
id="EmptyCourse.exploreCoursesPrompt"
/>
</p>
<Button
variant="brand"
>
<FormattedMessage
defaultMessage="Explore courses"
description="Button to explore more courses"
id="EmptyCourse.exploreCoursesButton"
/>
</Button>
</div>
<SuggestedCourses />
</div>
`;

View File

@@ -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 = () => (
<div className="p-3">
<div className="empty-course-hero">
<Image src={emptyCourseSVG} alt="empty course banner" />
<h1>
<FormattedMessage {...messages.lookingForChallengePrompt} />
</h1>
<p>
<FormattedMessage {...messages.exploreCoursesPrompt} />
</p>
<Button variant="brand">
<FormattedMessage {...messages.exploreCoursesButton} />
</Button>
</div>
<SuggestedCourses />
</div>
);
export default EmptyCourse;

View File

@@ -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(<EmptyCourse />)).toMatchSnapshot();
});
});

View File

@@ -7,7 +7,7 @@ exports[`WidgetSidebar snapshots default 1`] = `
<div
className="d-flex"
>
<LookingForChallengeWidget />
<RecommendationsPanel />
</div>
</div>
`;

View File

@@ -1,11 +1,11 @@
import React from 'react';
import LookingForChallengeWidget from './widgets/LookingForChallengeWidget';
import RecommendationsPanel from 'widgets/RecommendationsPanel';
export const WidgetSidebar = () => (
<div className="widget-sidebar">
<div className="d-flex">
<LookingForChallengeWidget />
<RecommendationsPanel />
</div>
</div>
);

View File

@@ -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', () => {

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WidgetSidebar snapshots default 1`] = `
<div
className="widget-sidebar px-2"
>
<div
className="d-flex"
>
<RecommendationsPanel />
</div>
</div>
`;

View File

@@ -0,0 +1,13 @@
import React from 'react';
import RecommendationsPanel from 'widgets/RecommendationsPanel';
export const WidgetSidebar = () => (
<div className="widget-sidebar px-2">
<div className="d-flex">
<RecommendationsPanel />
</div>
</div>
);
export default WidgetSidebar;

View File

@@ -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(<WidgetSidebar />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -18,6 +18,7 @@ export const RequestKeys = StrictDict({
enrollEntitlementSession: 'enrollEntitlementSession',
leaveEntitlementSession: 'leaveEntitlementSession',
masquerade: 'masquerade',
recommendedCourses: 'recommendedCourses',
});
export const ErrorCodes = StrictDict({

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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: () => ({

View File

@@ -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 (
<div className="p-4 w-100 panel-background">
<h3 className="pb-2">{formatMessage(messages.recommendationsHeading)}</h3>
<div>
{courses.map((course) => (
<CourseCard key={course.courseKey} course={course} />
))}
</div>
<div className="text-center explore-courses-btn">
<Button
variant="tertiary"
iconBefore={Search}
as="a"
href={courseSearchUrl}
>
{formatMessage(messages.exploreCoursesButton)}
</Button>
</div>
</div>
);
};
LoadedView.propTypes = {
courses: PropTypes.arrayOf(PropTypes.shape({
courseKey: PropTypes.string,
title: PropTypes.string,
logoImageUrl: PropTypes.string,
marketingUrl: PropTypes.string,
})).isRequired,
};
export default LoadedView;

View File

@@ -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(<LoadedView courses={mockData.courses} />)).toMatchSnapshot();
});
});

View File

@@ -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 (
<div className="recommendations-loading w-100">
<Spinner
animation="border"
variant="light"
screenReaderText={spinnerScreenReaderText}
/>
</div>
);
};
export default LoadingView;

View File

@@ -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(<LoadingView />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RecommendationsPanel LoadedView snapshot 1`] = `
<div
className="p-4 w-100 panel-background"
>
<h3
className="pb-2"
>
Recommendations for you
</h3>
<div>
<CourseCard
course={
Object {
"courseKey": "cs-1",
"logoImageUrl": "https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg",
"marketingUrl": "www.edx/recommended-course",
"title": "Recommended course 1",
}
}
key="cs-1"
/>
<CourseCard
course={
Object {
"courseKey": "cs-2",
"logoImageUrl": "https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg",
"marketingUrl": "www.edx/recommended-course",
"title": "Recommended course 2 with a really really really long name for some reason",
}
}
key="cs-2"
/>
<CourseCard
course={
Object {
"courseKey": "cs-3",
"logoImageUrl": "https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg",
"marketingUrl": "www.edx/recommended-course",
"title": "Recommended course 3",
}
}
key="cs-3"
/>
<CourseCard
course={
Object {
"courseKey": "cs-4",
"logoImageUrl": "https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg",
"marketingUrl": "www.edx/recommended-course",
"title": "Recommended course 4",
}
}
key="cs-4"
/>
</div>
<div
className="text-center explore-courses-btn"
>
<Button
as="a"
variant="tertiary"
>
Explore courses
</Button>
</div>
</div>
`;

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RecommendationsPanel LoadingView snapshot 1`] = `
<div
className="recommendations-loading w-100"
>
<Spinner
animation="border"
variant="light"
/>
</div>
`;

View File

@@ -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,
};

View File

@@ -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)),
);
});
});
});

View File

@@ -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 (
<Hyperlink destination={course?.marketingUrl} className="card-link">
<Card orientation={isCollapsed ? 'vertical' : 'horizontal'} className="p-3 mb-1 recommended-course-card">
<div className={isCollapsed ? '' : 'd-flex align-items-center'}>
<Card.ImageCap
src={course.logoImageUrl}
srcAlt={course.title}
/>
<Card.Body className="d-flex align-items-center">
<Card.Section className={isCollapsed ? 'pt-3' : 'pl-3'}>
<h4 className="text-info-500">
<Truncate lines={3}>
{course.title}
</Truncate>
</h4>
</Card.Section>
</Card.Body>
</div>
</Card>
</Hyperlink>
);
};
CourseCard.propTypes = {
course: PropTypes.shape({
courseKey: PropTypes.string,
title: PropTypes.string,
logoImageUrl: PropTypes.string,
marketingUrl: PropTypes.string,
}).isRequired,
};
export default CourseCard;

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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([]);
});
});
});
});
});

View File

@@ -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 (<LoadingView />);
}
if (isLoaded) {
return (<LoadedView courses={courses} />);
}
if (isFailed) {
return (<LookingForChallengeWidget />);
}
// default fallback
return (<LookingForChallengeWidget />);
};
export default RecommendationsPanel;

View File

@@ -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;
}

View File

@@ -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(<RecommendationsPanel />)).toMatchObject(shallow(<LoadingView />));
});
it('displays LoadedView with courses if request is loaded', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses,
isFailed: false,
isLoaded: true,
isLoading: false,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LoadedView courses={courses} />),
);
});
it('displays LookingForChallengeWidget if request is failed', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses: [],
isFailed: true,
isLoaded: false,
isLoading: false,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LookingForChallengeWidget />),
);
});
it('defaults to LookingForChallengeWidget if no flags are true', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses: [],
isFailed: false,
isLoaded: false,
isLoading: false,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LookingForChallengeWidget />),
);
});
});

View File

@@ -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;

View File

@@ -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;