feat: CourseCard and CourseCardActions i18n and tests
This commit is contained in:
65
src/containers/CourseCard/__snapshots__/index.test.jsx.snap
Normal file
65
src/containers/CourseCard/__snapshots__/index.test.jsx.snap
Normal file
@@ -0,0 +1,65 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseCard component snapshot 1`] = `
|
||||
<div
|
||||
className="mb-3"
|
||||
>
|
||||
<Card
|
||||
orientation="horizontal"
|
||||
>
|
||||
<Card.ImageCap
|
||||
src="hooks.bannerUrl"
|
||||
srcAlt={
|
||||
Object {
|
||||
"formatted": Object {
|
||||
"defaultMessage": "Course thumbnail",
|
||||
"description": "Course card banner alt-text",
|
||||
"id": "learner-dash.courseCard.bannerAlt",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
}
|
||||
title="hooks.title"
|
||||
/>
|
||||
<Card.Section>
|
||||
hooks.providerName
|
||||
•
|
||||
test-course-number
|
||||
•
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="vertical"
|
||||
textElement={
|
||||
<RelatedProgramsBadge
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CourseCardActions
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<div
|
||||
className="course-card-banners"
|
||||
>
|
||||
<CourseBanner
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
<CertificateBanner
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
<EntitlementBanner
|
||||
courseNumber="test-course-number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
import { getCardValues } from 'hooks';
|
||||
|
||||
const { cardData } = selectors;
|
||||
|
||||
export const CourseCardActions = ({ courseNumber }) => {
|
||||
const {
|
||||
canUpgrade,
|
||||
isAudit,
|
||||
isAuditAccessExpired,
|
||||
isVerified,
|
||||
isPending,
|
||||
isFinished,
|
||||
} = getCardValues(courseNumber, {
|
||||
canUpgrade: cardData.canUpgrade,
|
||||
isAudit: cardData.isAudit,
|
||||
isAuditAccessExpired: cardData.isAuditAccessExpired,
|
||||
isVerified: cardData.isVerified,
|
||||
isPending: cardData.isCourseRunPending,
|
||||
isFinished: cardData.isCourseRunFinished,
|
||||
});
|
||||
|
||||
let primary;
|
||||
let secondary = null;
|
||||
if (!isVerified) {
|
||||
secondary = (
|
||||
<Button
|
||||
iconBefore={Locked}
|
||||
variant="outline-primary"
|
||||
disabled={!canUpgrade}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
primary = (<Button>Begin Course</Button>);
|
||||
} else if (!isFinished) {
|
||||
primary = (isAudit && isAuditAccessExpired)
|
||||
? (<Button disabled>Resume</Button>)
|
||||
: (<Button>Resume</Button>);
|
||||
} else {
|
||||
primary = (<Button>View Course</Button>);
|
||||
}
|
||||
return (<>{secondary}{primary}</>);
|
||||
};
|
||||
CourseCardActions.propTypes = {
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseCardActions;
|
||||
@@ -0,0 +1,32 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseCard Actions component does not render secondary button if null is returned for secondary props 1`] = `
|
||||
<Fragment>
|
||||
<Button
|
||||
courseNumber="test-course-number"
|
||||
prop1="primary-prop1"
|
||||
prop2="primary-prop2"
|
||||
>
|
||||
primary-children
|
||||
</Button>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`CourseCard Actions component loads primary and secondary button props from hook 1`] = `
|
||||
<Fragment>
|
||||
<Button
|
||||
courseNumber="test-course-number"
|
||||
prop1="primary-prop1"
|
||||
prop2="primary-prop2"
|
||||
>
|
||||
primary-children
|
||||
</Button>
|
||||
<Button
|
||||
courseNumber="test-course-number"
|
||||
prop1="primary-prop1"
|
||||
prop2="primary-prop2"
|
||||
>
|
||||
primary-children
|
||||
</Button>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
import { useIntl, getCardValues } from 'hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const { cardData } = selectors;
|
||||
|
||||
export const actionHooks = ({ courseNumber }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const data = getCardValues(courseNumber, {
|
||||
canUpgrade: cardData.canUpgrade,
|
||||
isAudit: cardData.isAudit,
|
||||
isAuditAccessExpired: cardData.isAuditAccessExpired,
|
||||
isVerified: cardData.isVerified,
|
||||
isPending: cardData.isCourseRunPending,
|
||||
isFinished: cardData.isCourseRunFinished,
|
||||
});
|
||||
let primary;
|
||||
let secondary = null;
|
||||
if (!data.isVerified) {
|
||||
secondary = {
|
||||
iconBefore: Locked,
|
||||
variant: 'outline-primary',
|
||||
disabled: !data.canUpgrade,
|
||||
children: formatMessage(messages.upgrade),
|
||||
};
|
||||
}
|
||||
if (data.isPending) {
|
||||
primary = { children: formatMessage(messages.beginCourse) };
|
||||
} else if (!data.isFinished) {
|
||||
primary = {
|
||||
children: formatMessage(messages.resume),
|
||||
disabled: data.isAudit && data.isAuditAccessExpired,
|
||||
};
|
||||
} else {
|
||||
primary = { children: formatMessage(messages.viewCourse) };
|
||||
}
|
||||
return { primary, secondary };
|
||||
};
|
||||
|
||||
export default actionHooks;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import * as appHooks from 'hooks';
|
||||
import { testCardValues } from 'testUtils';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const courseNumber = 'my-test-course-number';
|
||||
const { fieldKeys } = selectors.cardData;
|
||||
|
||||
const props = {
|
||||
canUpgrade: false,
|
||||
isAudit: true,
|
||||
isAuditAccessExpired: false,
|
||||
isVerified: false,
|
||||
isPending: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
describe('CourseCardActions hooks', () => {
|
||||
let out;
|
||||
const { formatMessage } = appHooks.useIntl();
|
||||
describe('data connection', () => {
|
||||
beforeEach(() => {
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
});
|
||||
testCardValues(courseNumber, {
|
||||
canUpgrade: fieldKeys.canUpgrade,
|
||||
isAudit: fieldKeys.isAudit,
|
||||
isAuditAccessExpired: fieldKeys.isAuditAccessExpired,
|
||||
isVerified: fieldKeys.isVerified,
|
||||
isPending: fieldKeys.isCourseRunPending,
|
||||
isFinished: fieldKeys.isCourseRunFinished,
|
||||
});
|
||||
});
|
||||
describe('secondary action', () => {
|
||||
it('returns null if verified', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
...props,
|
||||
isAudit: false,
|
||||
isVerified: true,
|
||||
});
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.secondary).toEqual(null);
|
||||
});
|
||||
it('returns disabled upgrade button if audit, but cannot upgrade', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce(props);
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.secondary).toEqual({
|
||||
iconBefore: Locked,
|
||||
variant: 'outline-primary',
|
||||
disabled: true,
|
||||
children: formatMessage(messages.upgrade),
|
||||
});
|
||||
});
|
||||
it('returns enabled upgrade button if audit and can upgrade', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props, canUpgrade: true });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.secondary).toEqual({
|
||||
iconBefore: Locked,
|
||||
variant: 'outline-primary',
|
||||
disabled: false,
|
||||
children: formatMessage(messages.upgrade),
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('primary action', () => {
|
||||
it('returns Begin Course button if pending', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props, isPending: true });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.primary).toEqual({
|
||||
children: formatMessage(messages.beginCourse),
|
||||
});
|
||||
});
|
||||
it('returns enabled Resume button if active, and not audit with expired access', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props, isAuditAccessExpired: true });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.primary).toEqual({
|
||||
children: formatMessage(messages.resume),
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
it('returns disabled Resume button if active and audit without expired access', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.primary).toEqual({
|
||||
children: formatMessage(messages.resume),
|
||||
disabled: false,
|
||||
});
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props, isAudit: false, isVerified: true });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.primary).toEqual({
|
||||
children: formatMessage(messages.resume),
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
it('returns viewCourse button if finished', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({ ...props, isFinished: true });
|
||||
out = hooks.actionHooks({ courseNumber });
|
||||
expect(out.primary).toEqual({
|
||||
children: formatMessage(messages.viewCourse),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import hooks from './hooks';
|
||||
|
||||
export const CourseCardActions = ({ courseNumber }) => {
|
||||
const { primary, secondary } = hooks({ courseNumber });
|
||||
return (
|
||||
<>
|
||||
{(secondary !== null) && (
|
||||
<Button {...secondary} />
|
||||
)}
|
||||
<Button {...primary} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
CourseCardActions.propTypes = {
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseCardActions;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import hooks from './hooks';
|
||||
import CourseCardActions from '.';
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const courseNumber = 'test-course-number';
|
||||
|
||||
describe('CourseCard Actions component', () => {
|
||||
it('loads primary and secondary button props from hook', () => {
|
||||
const mockHook = (args) => ({
|
||||
primary: {
|
||||
prop1: 'primary-prop1',
|
||||
prop2: 'primary-prop2',
|
||||
children: 'primary-children',
|
||||
courseNumber: args.courseNumber,
|
||||
},
|
||||
secondary: {
|
||||
prop1: 'primary-prop1',
|
||||
prop2: 'primary-prop2',
|
||||
children: 'primary-children',
|
||||
courseNumber: args.courseNumber,
|
||||
},
|
||||
});
|
||||
hooks.mockImplementationOnce(mockHook);
|
||||
expect(shallow(<CourseCardActions courseNumber={courseNumber} />)).toMatchSnapshot();
|
||||
});
|
||||
it('does not render secondary button if null is returned for secondary props', () => {
|
||||
const mockHook = (args) => ({
|
||||
primary: {
|
||||
prop1: 'primary-prop1',
|
||||
prop2: 'primary-prop2',
|
||||
children: 'primary-children',
|
||||
courseNumber: args.courseNumber,
|
||||
},
|
||||
secondary: null,
|
||||
});
|
||||
hooks.mockImplementationOnce(mockHook);
|
||||
expect(shallow(<CourseCardActions courseNumber={courseNumber} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
export const messages = StrictDict({
|
||||
upgrade: {
|
||||
id: 'learner-dash.courseCard.actions.upgrade',
|
||||
description: 'Course card upgrade button text',
|
||||
defaultMessage: 'Upgrade',
|
||||
},
|
||||
beginCourse: {
|
||||
id: 'learner-dash.courseCard.actions.beginCourse',
|
||||
description: 'Course card begin-course button text',
|
||||
defaultMessage: 'Begin Course',
|
||||
},
|
||||
resume: {
|
||||
id: 'learner-dash.courseCard.actions.resume',
|
||||
description: 'Course card resume button text',
|
||||
defaultMessage: 'Resume',
|
||||
},
|
||||
viewCourse: {
|
||||
id: 'learner-dash.courseCard.actions.viewCourse',
|
||||
description: 'Course card view-course button text',
|
||||
defaultMessage: 'View Course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
49
src/containers/CourseCard/hooks.js
Normal file
49
src/containers/CourseCard/hooks.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import { useIntl, getCardValues } from 'hooks';
|
||||
|
||||
import * as module from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const { cardData } = selectors;
|
||||
|
||||
export const accessMessage = ({ courseNumber }) => {
|
||||
const { formatMessage, formatDate } = useIntl();
|
||||
|
||||
const data = getCardValues(courseNumber, {
|
||||
accessExpirationDate: cardData.courseRunAccessExpirationDate,
|
||||
isAudit: cardData.isAudit,
|
||||
isFinished: cardData.isCourseRunFinished,
|
||||
isAuditAccessExpired: cardData.isAuditAccessExpired,
|
||||
endDate: cardData.courseRunEndDate,
|
||||
});
|
||||
if (data.isAudit) {
|
||||
return formatMessage(
|
||||
data.isAuditAccessExpired ? messages.accessExpired : messages.accessExpires,
|
||||
{ accessExpirationDate: formatDate(data.accessExpirationDate) },
|
||||
);
|
||||
}
|
||||
return formatMessage(
|
||||
data.isFinished ? messages.courseEnded : messages.courseEnds,
|
||||
{ endDate: formatDate(data.endDate) },
|
||||
);
|
||||
};
|
||||
|
||||
export const cardHooks = ({ courseNumber }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const data = getCardValues(courseNumber, {
|
||||
title: cardData.courseTitle,
|
||||
bannerUrl: cardData.courseBannerUrl,
|
||||
providerName: cardData.providerName,
|
||||
});
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
bannerUrl: data.bannerUrl,
|
||||
providerName: data.providerName || formatMessage(messages.unknownProviderName),
|
||||
accessMessage: module.accessMessage({ courseNumber }),
|
||||
formatMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export default cardHooks;
|
||||
125
src/containers/CourseCard/hooks.test.js
Normal file
125
src/containers/CourseCard/hooks.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { keyStore } from 'utils';
|
||||
import { selectors } from 'data/redux';
|
||||
import * as appHooks from 'hooks';
|
||||
import { testCardValues } from 'testUtils';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const { fieldKeys } = selectors.cardData;
|
||||
|
||||
const courseNumber = 'my-test-course-number';
|
||||
|
||||
describe('CourseCard hooks', () => {
|
||||
let out;
|
||||
const { formatMessage, formatDate } = appHooks.useIntl();
|
||||
describe('cardHooks', () => {
|
||||
const accessMessage = 'test-access-message';
|
||||
const mockAccessMessage = (args) => ({ courseNumber: args.coursenumber, accessMessage });
|
||||
const hookKeys = keyStore(hooks);
|
||||
beforeEach(() => {
|
||||
jest.spyOn(hooks, hookKeys.accessMessage).mockImplementationOnce(mockAccessMessage);
|
||||
out = hooks.cardHooks({ courseNumber });
|
||||
});
|
||||
testCardValues(courseNumber, {
|
||||
title: fieldKeys.courseTitle,
|
||||
bannerUrl: fieldKeys.courseBannerUrl,
|
||||
providerName: fieldKeys.providerName,
|
||||
});
|
||||
test('providerName returns Unknown message if not provided', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
title: 'title',
|
||||
bannerUrl: 'bannerUrl',
|
||||
providerName: null,
|
||||
});
|
||||
jest.spyOn(hooks, hookKeys.accessMessage).mockImplementationOnce(mockAccessMessage);
|
||||
out = hooks.cardHooks({ courseNumber });
|
||||
expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName));
|
||||
});
|
||||
describe('accessMessage', () => {
|
||||
it('returns the output of accessMessage hook, passed courseNumber', () => {
|
||||
expect(out.accessMessage).toEqual(mockAccessMessage({ courseNumber }));
|
||||
});
|
||||
});
|
||||
it('forwards formatMessage from useIntl', () => {
|
||||
expect(out.formatMessage).toEqual(formatMessage);
|
||||
});
|
||||
});
|
||||
describe('accessMessage', () => {
|
||||
const accessExpirationDate = 'test-expiration-date';
|
||||
const endDate = 'test-end-date';
|
||||
describe('loaded data', () => {
|
||||
beforeEach(() => {
|
||||
appHooks.getCardValues.mockClear();
|
||||
out = hooks.accessMessage({ courseNumber });
|
||||
});
|
||||
testCardValues(courseNumber, {
|
||||
accessExpirationDate: fieldKeys.courseRunAccessExpirationDate,
|
||||
isAudit: fieldKeys.isAudit,
|
||||
isFinished: fieldKeys.isCourseRunFinished,
|
||||
isAuditAccessExpired: fieldKeys.isAuditAccessExpired,
|
||||
endDate: fieldKeys.courseRunEndDate,
|
||||
});
|
||||
});
|
||||
describe('if audit, and expired', () => {
|
||||
it('returns accessExpires message with accessExpirationDate from cardData', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
accessExpirationDate,
|
||||
endDate,
|
||||
isAudit: true,
|
||||
isFinished: false,
|
||||
isAuditAccessExpired: true,
|
||||
});
|
||||
expect(hooks.accessMessage({ courseNumber })).toEqual(formatMessage(
|
||||
messages.accessExpired,
|
||||
{ accessExpirationDate: formatDate(accessExpirationDate) },
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('if audit and not expired', () => {
|
||||
it('returns accessExpires message with accessExpirationDate from cardData', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
accessExpirationDate,
|
||||
endDate,
|
||||
isAudit: true,
|
||||
isFinished: false,
|
||||
isAuditAccessExpired: false,
|
||||
});
|
||||
expect(hooks.accessMessage({ courseNumber })).toEqual(formatMessage(
|
||||
messages.accessExpires,
|
||||
{ accessExpirationDate: formatDate(accessExpirationDate) },
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('if verified and not ended', () => {
|
||||
it('returns accessExpires message with accessExpirationDate from cardData', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
accessExpirationDate,
|
||||
endDate,
|
||||
isAudit: false,
|
||||
isFinished: false,
|
||||
isAuditAccessExpired: true,
|
||||
});
|
||||
expect(hooks.accessMessage({ courseNumber })).toEqual(formatMessage(
|
||||
messages.courseEnds,
|
||||
{ endDate: formatDate(endDate) },
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('if verified and ended', () => {
|
||||
it('returns accessExpires message with accessExpirationDate from cardData', () => {
|
||||
appHooks.getCardValues.mockReturnValueOnce({
|
||||
accessExpirationDate,
|
||||
endDate,
|
||||
isAudit: false,
|
||||
isFinished: true,
|
||||
isAuditAccessExpired: true,
|
||||
});
|
||||
expect(hooks.accessMessage({ courseNumber })).toEqual(formatMessage(
|
||||
messages.courseEnded,
|
||||
{ endDate: formatDate(endDate) },
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,7 @@ import PropTypes from 'prop-types';
|
||||
// import PropTypes from 'prop-types';
|
||||
import { Card } from '@edx/paragon';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import { getCardValues } from 'hooks';
|
||||
import hooks from './hooks';
|
||||
|
||||
import RelatedProgramsBadge from './components/RelatedProgramsBadge';
|
||||
import CourseCardMenu from './components/CourseCardMenu';
|
||||
@@ -16,27 +14,22 @@ import {
|
||||
EntitlementBanner,
|
||||
} from './components/Banners';
|
||||
import CourseCardActions from './components/CourseCardActions';
|
||||
|
||||
const { cardData } = selectors;
|
||||
import messages from './messages';
|
||||
|
||||
export const CourseCard = ({ courseNumber }) => {
|
||||
const {
|
||||
title,
|
||||
bannerUrl,
|
||||
accessExpirationDate,
|
||||
providerName,
|
||||
} = getCardValues(courseNumber, {
|
||||
title: cardData.courseTitle,
|
||||
bannerUrl: cardData.courseBannerUrl,
|
||||
accessExpirationDate: cardData.courseRunAccessExpirationDate,
|
||||
providerName: cardData.providerName,
|
||||
});
|
||||
accessMessage,
|
||||
formatMessage,
|
||||
} = hooks({ courseNumber });
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<Card orientation="horizontal">
|
||||
<Card.ImageCap
|
||||
src={bannerUrl}
|
||||
srcAlt="course thumbnail"
|
||||
srcAlt={formatMessage(messages.bannerAlt)}
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
@@ -44,7 +37,7 @@ export const CourseCard = ({ courseNumber }) => {
|
||||
actions={<CourseCardMenu courseNumber={courseNumber} />}
|
||||
/>
|
||||
<Card.Section>
|
||||
{providerName || 'Unkown'} • {courseNumber} • Access expires {accessExpirationDate}
|
||||
{providerName} • {courseNumber} • {accessMessage}
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="vertical"
|
||||
|
||||
37
src/containers/CourseCard/index.test.jsx
Normal file
37
src/containers/CourseCard/index.test.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import CourseCard from '.';
|
||||
import hooks from './hooks';
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./components/RelatedProgramsBadge', () => 'RelatedProgramsBadge');
|
||||
jest.mock('./components/CourseCardMenu', () => 'CourseCardMenu');
|
||||
jest.mock('./components/banners', () => ({
|
||||
CourseBanner: () => 'CourseBanner',
|
||||
CertificateBanner: () => 'CertificateBanner',
|
||||
EntitlementBanner: () => 'EntitlementBanner',
|
||||
}));
|
||||
jest.mock('./components/CourseCardActions', () => 'CourseCardActions');
|
||||
|
||||
const dataProps = {
|
||||
title: 'hooks.title',
|
||||
bannerUrl: 'hooks.bannerUrl',
|
||||
providerName: 'hooks.providerName',
|
||||
accessMessagE: 'hooks.accessMessage',
|
||||
formatMessage: jest.fn(msg => ({ formatted: msg })),
|
||||
};
|
||||
|
||||
const courseNumber = 'test-course-number';
|
||||
|
||||
describe('CourseCard component', () => {
|
||||
test('snapshot', () => {
|
||||
hooks.mockReturnValueOnce(dataProps);
|
||||
expect(shallow(<CourseCard courseNumber={courseNumber} />)).toMatchSnapshot();
|
||||
expect(hooks).toHaveBeenCalledWith({ courseNumber });
|
||||
});
|
||||
});
|
||||
36
src/containers/CourseCard/messages.js
Normal file
36
src/containers/CourseCard/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
export const messages = StrictDict({
|
||||
accessExpired: {
|
||||
id: 'learner-dash.courseCard.accessExpired',
|
||||
description: 'Course access expiration date message on course card for expired access.',
|
||||
defaultMessage: 'Access expired {accessExpirationDate}',
|
||||
},
|
||||
accessExpires: {
|
||||
id: 'learner-dash.courseCard.accessExpires',
|
||||
description: 'Course access expiration date message on course card.',
|
||||
defaultMessage: 'Access expires {accessExpirationDate}',
|
||||
},
|
||||
courseEnded: {
|
||||
id: 'learner-dash.courseCard.courseEnded',
|
||||
description: 'Course ended message on course card.',
|
||||
defaultMessage: 'Course ended {endDate}',
|
||||
},
|
||||
courseEnds: {
|
||||
id: 'learner-dash.courseCard.courseEnds',
|
||||
description: 'Course ending message on course card.',
|
||||
defaultMessage: 'Course ends {endDate}',
|
||||
},
|
||||
bannerAlt: {
|
||||
id: 'learner-dash.courseCard.bannerAlt',
|
||||
description: 'Course card banner alt-text',
|
||||
defaultMessage: 'Course thumbnail',
|
||||
},
|
||||
unknownProviderName: {
|
||||
id: 'learner-dash.courseCard.unknownProviderName',
|
||||
description: 'Provider name display when name is unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -11,6 +11,7 @@ export const fieldSelectors = {
|
||||
courseTitle: data => data.course.title,
|
||||
courseBannerUrl: data => data.course.bannerUrl,
|
||||
courseRunAccessExpirationDate: data => data.courseRun.accessExpirationDate,
|
||||
courseRunEndDate: data => data.courseRun.endDate,
|
||||
courseWebsite: data => data.course.website,
|
||||
providerName: data => data.provider?.name,
|
||||
isVerified: data => data.enrollment.isVerified,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const genCourseRunData = (data = {}) => ({
|
||||
isFinished: false,
|
||||
isArchived: false,
|
||||
accessExpirationDate: futureDate,
|
||||
endDate: futureDate,
|
||||
minPassingGrade: 70,
|
||||
...data,
|
||||
});
|
||||
@@ -140,7 +141,7 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, pending, restricted
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isPending: true },
|
||||
certificates: genCertificateData({ isRestricted: true }),
|
||||
@@ -148,7 +149,7 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, started
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData(),
|
||||
@@ -156,7 +157,7 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, not passing
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: false },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData(),
|
||||
@@ -164,9 +165,9 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, finished, not passing
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: false },
|
||||
courseRun: { isFinished: true },
|
||||
courseRun: { isFinished: true, endDate: pastDate },
|
||||
certificates: genCertificateData(),
|
||||
entitlements: { isEntitlement: false },
|
||||
},
|
||||
@@ -191,7 +192,7 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, earned, downloadable (web + link)
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData({
|
||||
@@ -208,7 +209,7 @@ export const courseRuns = [
|
||||
},
|
||||
// verified, earned, downloadable (link)
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData({
|
||||
@@ -224,7 +225,7 @@ export const courseRuns = [
|
||||
},
|
||||
// Entitlement Course Run - Cannot view yet
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isPending: true },
|
||||
certificates: genCertificateData(),
|
||||
@@ -240,7 +241,7 @@ export const courseRuns = [
|
||||
},
|
||||
// Entitlement Course Run - Can View and Change
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData(),
|
||||
@@ -256,7 +257,7 @@ export const courseRuns = [
|
||||
},
|
||||
// Entitlement Course Run - Can View but not Change
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true },
|
||||
certificates: genCertificateData(),
|
||||
@@ -272,9 +273,14 @@ export const courseRuns = [
|
||||
},
|
||||
// Entitlement Course Run - Expired
|
||||
{
|
||||
enrollment: genEnrollmentData({ isVerified: true }),
|
||||
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
|
||||
grades: { isPassing: true },
|
||||
courseRun: { isStarted: true, isFinished: true, isArchived: true },
|
||||
courseRun: {
|
||||
isStarted: true,
|
||||
isFinished: true,
|
||||
isArchived: true,
|
||||
endDate: pastDate,
|
||||
},
|
||||
certificates: genCertificateData(),
|
||||
entitlements: {
|
||||
isEntitlement: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
@@ -18,6 +19,10 @@ export const getCardValues = (courseNumber, mapping) => {
|
||||
|
||||
export const nullMethod = () => ({});
|
||||
|
||||
export { useIntl };
|
||||
|
||||
export default {
|
||||
getCardValues,
|
||||
nullMethod,
|
||||
useIntl,
|
||||
};
|
||||
|
||||
@@ -39,6 +39,9 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
||||
Card: {
|
||||
Body: 'Card.Body',
|
||||
Footer: 'Card.Footer',
|
||||
Header: 'Card.Header',
|
||||
ImageCap: 'Card.ImageCap',
|
||||
Section: 'Card.Section',
|
||||
},
|
||||
Col: 'Col',
|
||||
Collapsible: {
|
||||
@@ -129,3 +132,24 @@ jest.mock('react-redux', () => {
|
||||
useSelector: jest.fn((selector) => ({ useSelector: selector })),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('hooks', () => {
|
||||
const formatMessage = jest.fn((msg, values) => ({ formatted: { msg, values } }));
|
||||
return {
|
||||
...jest.requireActual('hooks'),
|
||||
useIntl: () => ({
|
||||
formatMessage,
|
||||
formatDate: jest.fn((date) => ({ formatted: date })),
|
||||
}),
|
||||
getCardValues: jest.fn((courseNumber, mapping) => (
|
||||
Object.keys(mapping).reduce(
|
||||
(obj, key) => ({
|
||||
...obj,
|
||||
[key]: { selector: mapping[key], courseNumber },
|
||||
}),
|
||||
{},
|
||||
)
|
||||
)),
|
||||
nullMethod: jest.fn().mockName('hooks.nullMethod'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import react from 'react';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import * as appHooks from 'hooks';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
const { cardData } = selectors;
|
||||
|
||||
/**
|
||||
* Mocked formatMessage provided by react-intl
|
||||
*/
|
||||
@@ -185,3 +191,23 @@ export class MockUseState {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that getCardValues was called with the given courseNumber and selector mapping.
|
||||
* @param {string} courseNumber - course run identifier
|
||||
* @param {obj} mapping - value mapping { <requestedKey>: <selectorFieldKey> }
|
||||
*/
|
||||
export const testCardValues = (courseNumber, mapping) => {
|
||||
describe('cardData values', () => {
|
||||
let mapped;
|
||||
test('passess correct courseNumber', () => {
|
||||
expect(appHooks.getCardValues.mock.calls[0][0]).toEqual(courseNumber);
|
||||
});
|
||||
Object.keys(mapping).forEach(key => {
|
||||
test(`loads ${key} from card data ${mapping[key]} selector`, () => {
|
||||
[[, mapped]] = appHooks.getCardValues.mock.calls;
|
||||
expect(mapped[key]).toEqual(cardData[mapping[key]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user