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

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LookingForChallengeWidget snapshots default 1`] = `
<Card
id="looking-for-challenge-widget"
orientation="horizontal"
>
<Card.ImageCap
src="icon/mock/path"
srcAlt="course side widget"
/>
<Card.Body
className="m-auto pr-2"
>
<h4>
Looking for a new challenge?
</h4>
<h5>
<Hyperlink
className="d-flex align-items-center"
destination="course-search-url"
variant="brand"
>
<format-message-function
message={
Object {
"defaultMessage": "Find a course {arrow}",
"description": "Button to explore more courses",
"id": "WidgetSidebar.findCoursesButton",
}
}
values={
Object {
"arrow": <Icon
className="mx-1"
/>,
}
}
/>
</Hyperlink>
</h5>
</Card.Body>
</Card>
`;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card, Hyperlink, Icon } from '@edx/paragon';
import { ArrowForward } from '@edx/paragon/icons';
import { hooks } from 'data/redux';
import moreCoursesSVG from 'assets/more-courses-sidewidget.svg';
import messages from './messages';
import './index.scss';
export const arrowIcon = (<Icon className="mx-1" src={ArrowForward} />);
export const LookingForChallengeWidget = () => {
const { formatMessage } = useIntl();
const { courseSearchUrl } = hooks.usePlatformSettingsData();
return (
<Card orientation="horizontal" id="looking-for-challenge-widget">
<Card.ImageCap
src={moreCoursesSVG}
srcAlt="course side widget"
/>
<Card.Body className="m-auto pr-2">
<h4>
{formatMessage(messages.lookingForChallengePrompt)}
</h4>
<h5>
<Hyperlink variant="brand" destination={courseSearchUrl} className="d-flex align-items-center">
{formatMessage(messages.findCoursesButton, { arrow: arrowIcon })}
</Hyperlink>
</h5>
</Card.Body>
</Card>
);
};
export default LookingForChallengeWidget;

View File

@@ -0,0 +1,6 @@
#looking-for-challenge-widget {
.pgn__card-wrapper-image-cap {
overflow: visible;
min-width: auto;
}
}

View File

@@ -0,0 +1,20 @@
import { shallow } from 'enzyme';
import LookingForChallengeWidget from '.';
jest.mock('data/redux', () => ({
hooks: {
usePlatformSettingsData: () => ({
courseSearchUrl: 'course-search-url',
}),
},
}));
describe('LookingForChallengeWidget', () => {
describe('snapshots', () => {
test('default', () => {
const wrapper = shallow(<LookingForChallengeWidget />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
lookingForChallengePrompt: {
id: 'WidgetSidebar.lookingForChallengePrompt',
defaultMessage: 'Looking for a new challenge?',
description: 'Prompt user for new challenge',
},
findCoursesButton: {
id: 'WidgetSidebar.findCoursesButton',
defaultMessage: 'Find a course {arrow}',
description: 'Button to explore more courses',
},
});
export default messages;

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;