Merge pull request #2 from edx/bw/banner-tests

chore: Banner tests and api updates
This commit is contained in:
Ben Warzeski
2022-07-22 12:18:50 -04:00
committed by GitHub
43 changed files with 1157 additions and 981 deletions

View File

@@ -2,82 +2,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@edx/paragon';
import { MailtoLink, Hyperlink } from '@edx/paragon';
import { CheckCircle } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux';
import { hooks as appHooks } from 'data/redux';
import Banner from 'components/Banner';
import { useCardValues } from 'hooks';
const { cardData } = selectors;
const restrictedMessage = 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting ';
import messages from './messages';
export const CertificateBanner = ({ courseNumber }) => {
const data = useCardValues(courseNumber, {
certAvailableDate: cardData.certAvailableDate,
certDownloadUrl: cardData.certDownloadUrl,
certPreviewUrl: cardData.certPreviewUrl,
isAudit: cardData.isAudit,
isCertDownloadable: cardData.isCertDownloadable,
isCertEarnedButUnavailable: cardData.isCertEarnedButUnavailable,
isCourseRunFinished: cardData.isCourseRunFinished,
isPassing: cardData.isPassing,
isRestricted: cardData.isRestricted,
isVerified: cardData.isVerified,
minPassingGrade: cardData.minPassingGrade,
});
if (data.isRestricted) {
const certificate = appHooks.useCardCertificateData(courseNumber);
const {
isAudit,
isVerified,
hasFinished,
} = appHooks.useCardEnrollmentData(courseNumber);
const { isPassing } = appHooks.useCardGradeData(courseNumber);
const { minPassingGrade, progressUrl } = appHooks.useCardCourseRunData(courseNumber);
const { supportEmail, billingEmail } = appHooks.usePlatformSettingsData();
const { formatMessage } = useIntl();
const emailLink = address => address && <MailtoLink to={address}>{address}</MailtoLink>;
if (certificate.isRestricted) {
return (
<Banner variant="danger">
{restrictedMessage}<Hyperlink destination="info@example.com">info@example.com</Hyperlink>
{data.isVerified && (
<>
If you would like a refund on your Certificate of Achievement, please contact our billing address <Hyperlink destination="billing@example.com">billing@example.com</Hyperlink>
</>
{formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) })}
{isVerified && ' '}
{isVerified && formatMessage(
messages.certRefundContactBilling,
{ billingEmail: emailLink(billingEmail) },
)}
</Banner>
);
}
if (!data.isPassing) {
if (data.isAudit) {
return (<Banner> Grade required to pass the course: {data.minPassingGrade}% </Banner>);
if (!isPassing) {
if (isAudit) {
return (
<Banner>
{formatMessage(messages.passingGrade, { minPassingGrade })}
</Banner>
);
}
if (data.isCourseRunFinished) {
if (hasFinished) {
return (
<Banner variant="warning">
You are not eligible for a certificate. <Hyperlink destination="">View grades.</Hyperlink>
{formatMessage(messages.notEligibleForCert)}.
{' '}
<Hyperlink destination={progressUrl}>{formatMessage(messages.viewGrades)}</Hyperlink>
</Banner>
);
}
return (
<Banner variant="warning">
Grade required for a certificate: {data.minPassingGrade}%
{formatMessage(messages.certMinGrade, { minPassingGrade })}
</Banner>
);
}
if (data.isCertDownloadable) {
if (data.certPreviewUrl) {
if (certificate.isDownloadable) {
if (certificate.certPreviewUrl) {
return (
<Banner variant="success" icon={CheckCircle}>
Congratulations. Your certificate is ready.
{formatMessage(messages.certReady)}
{' '}
<Hyperlink destination={data.certPreviewUrl}>View Certificate.</Hyperlink>
<Hyperlink destination={certificate.certPreviewUrl}>
{formatMessage(messages.viewCertificate)}
</Hyperlink>
</Banner>
);
}
return (
<Banner variant="success" icon={CheckCircle}>
Congratulations. Your certificate is ready.
{formatMessage(messages.certReady)}
{' '}
<Hyperlink destination={data.certDownloadUrl}>Download Certificate.</Hyperlink>
<Hyperlink destination={certificate.certDownloadUrl}>
{formatMessage(messages.downloadCertificate)}
</Hyperlink>
</Banner>
);
}
if (data.isCertEarnedButUnavailable) {
if (certificate.isEarnedButUnavailable) {
return (
<Banner>
Your grade and certificate will be ready after {data.certAvailableDate}.
{formatMessage(
messages.gradeAndCertReadyAfter,
{ availableDate: certificate.availableDate },
)}
</Banner>
);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Hyperlink } from '@edx/paragon';
import { hooks as appHooks } from 'data/redux';
import { CourseBanner } from './CourseBanner';
import messages from './messages';
jest.mock('components/Banner', () => 'Banner');
jest.mock('data/redux', () => ({
hooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
let el;
const enrollmentData = {
isVerified: false,
canUpgrade: false,
isAuditAccessExpired: false,
};
const courseRunData = {
isActive: false,
};
const courseData = {
website: 'test-course-website',
};
const render = (overrides = {}) => {
const {
course = {},
courseRun = {},
enrollment = {},
} = overrides;
appHooks.useCardCourseData.mockReturnValueOnce({
...courseData,
...course,
});
appHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
appHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
el = shallow(<CourseBanner courseNumber={courseNumber} />);
};
describe('CourseBanner', () => {
it('initializes data with course number from enrollment, course and course run data', () => {
render();
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber);
});
test('no display if learner is verified', () => {
render({ enrollment: { isVerified: true } });
expect(el.isEmptyRender()).toEqual(true);
});
describe('audit access expired, can upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } });
});
test('snapshot: (auditAccessExpired, upgradeToAccess)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.text()).toContain(messages.upgradeToAccess.defaultMessage);
});
});
describe('audit access expired, cannot upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true } });
});
test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.find(Hyperlink).text()).toEqual(messages.findAnotherCourse.defaultMessage);
});
});
describe('course run active and cannot upgrade', () => {
beforeEach(() => {
render({ courseRun: { isActive: true } });
});
test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage);
const link = el.find(Hyperlink);
expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage);
expect(link.props().destination).toEqual(courseData.website);
});
});
test('no display if audit access not expired and (course is not active or can upgrade)', () => {
render();
expect(el.isEmptyRender()).toEqual(true);
render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } });
expect(el.isEmptyRender()).toEqual(true);
});
});

View File

@@ -2,45 +2,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCardValues } from 'hooks';
import { selectors } from 'data/redux';
import { hooks as appHooks } from 'data/redux';
import Banner from 'components/Banner';
const { cardData } = selectors;
import messages from './messages';
export const CourseBanner = ({ courseNumber }) => {
const courseData = useCardValues(courseNumber, {
isVerified: cardData.isVerified,
isCourseRunActive: cardData.isCourseRunActive,
canUpgrade: cardData.canUpgrade,
isAuditAccessExpired: cardData.isAuditAccessExpired,
courseWebsite: cardData.courseWebsite,
});
const {
isVerified,
isAuditAccessExpired,
canUpgrade,
} = appHooks.useCardEnrollmentData(courseNumber);
const courseRun = appHooks.useCardCourseRunData(courseNumber);
const course = appHooks.useCardCourseData(courseNumber);
const { formatMessage } = useIntl();
if (courseData.isVerified) { return null; }
if (isVerified) { return null; }
if (courseData.isAuditAccessExpired) {
if (courseData.canUpgrade) {
if (isAuditAccessExpired) {
if (canUpgrade) {
return (
<Banner>
Your audit access to this course has expired. Upgrade now to access your course again.
{formatMessage(messages.auditAccessExpired)}
{' '}
{formatMessage(messages.upgradeToAccess)}
</Banner>
);
}
return (
<Banner>
Your audit access to this course has expired. <Hyperlink destination="">Find another course</Hyperlink>
{formatMessage(messages.auditAccessExpired)}
{' '}
<Hyperlink destination="">{formatMessage(messages.findAnotherCourse)}</Hyperlink>
</Banner>
);
}
if (courseData.isCourseRunActive && !courseData.canUpgrade) {
if (courseRun.isActive && !canUpgrade) {
return (
<Banner>
Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.
{formatMessage(messages.upgradeDeadlinePassed)}
{' '}
<Hyperlink destination={courseData.courseWebsite || ''}>Explore course details.</Hyperlink>
<Hyperlink destination={course.website || ''}>
{formatMessage(messages.exploreCourseDetails)}
</Hyperlink>
</Banner>
);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Hyperlink } from '@edx/paragon';
import { hooks as appHooks } from 'data/redux';
import { CourseBanner } from './CourseBanner';
import messages from './messages';
jest.mock('components/Banner', () => 'Banner');
jest.mock('data/redux', () => ({
hooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
let el;
const enrollmentData = {
isVerified: false,
canUpgrade: false,
isAuditAccessExpired: false,
};
const courseRunData = {
isActive: false,
};
const courseData = {
website: 'test-course-website',
};
const render = (overrides = {}) => {
const {
course = {},
courseRun = {},
enrollment = {},
} = overrides;
appHooks.useCardCourseData.mockReturnValueOnce({
...courseData,
...course,
});
appHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
appHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
el = shallow(<CourseBanner courseNumber={courseNumber} />);
};
describe('CourseBanner', () => {
it('initializes data with course number from enrollment, course and course run data', () => {
render();
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber);
});
test('no display if learner is verified', () => {
render({ enrollment: { isVerified: true } });
expect(el.isEmptyRender()).toEqual(true);
});
describe('audit access expired, can upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } });
});
test('snapshot: (auditAccessExpired, upgradeToAccess)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.text()).toContain(messages.upgradeToAccess.defaultMessage);
});
});
describe('audit access expired, cannot upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true } });
});
test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.find(Hyperlink).text()).toEqual(messages.findAnotherCourse.defaultMessage);
});
});
describe('course run active and cannot upgrade', () => {
beforeEach(() => {
render({ courseRun: { isActive: true } });
});
test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage);
const link = el.find(Hyperlink);
expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage);
expect(link.props().destination).toEqual(courseData.website);
});
});
test('no display if audit access not expired and (course is not active or can upgrade)', () => {
render();
expect(el.isEmptyRender()).toEqual(true);
render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } });
expect(el.isEmptyRender()).toEqual(true);
});
});

View File

@@ -1,30 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, MailtoLink } from '@edx/paragon';
import { useCardValues } from 'hooks';
import { selectors } from 'data/redux';
import { hooks as appHooks } from 'data/redux';
import Banner from 'components/Banner';
const { cardData } = selectors;
import messages from './messages';
export const EntitlementBanner = ({ courseNumber }) => {
const data = useCardValues(courseNumber, {
canChange: cardData.canChangeEntitlementSession,
isEntitlement: cardData.isEntitlement,
isExpired: cardData.isEntitlementExpired,
isFulfilled: cardData.isEntitlementFulfilled,
});
const {
isEntitlement,
hasSessions,
isFulfilled,
changeDeadline,
showExpirationWarning,
} = appHooks.useCardEntitlementsData(courseNumber);
const { supportEmail } = appHooks.usePlatformSettingsData();
const { formatDate, formatMessage } = useIntl();
if (!data.isEntitlement) {
if (!isEntitlement) {
return null;
}
if (data.isExpired || data.isFulfilled) {
return null;
if (!hasSessions && !isFulfilled) {
return (
<Banner variant="warning">
{formatMessage(messages.entitlementsUnavailable, {
emailLink: supportEmail && <MailtoLink to={supportEmail}>{supportEmail}</MailtoLink>,
})}
</Banner>
);
}
return data.canChange
? (<Banner>You must select a session to access the course.</Banner>)
: (<Banner>The deadline to select a session has passed</Banner>);
if (showExpirationWarning) {
return (
<Banner>
{formatMessage(messages.entitlementsExpiringSoon, {
changeDeadline: formatDate(changeDeadline),
selectSessionButton: (
<Button variant="link" size="inline" className="m-0 p-0">
{formatMessage(messages.selectSession)}
</Button>
),
})}
</Banner>
);
}
return null;
};
EntitlementBanner.propTypes = {
courseNumber: PropTypes.string.isRequired,

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import EntitlementBanner from './EntitlementBanner';
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: jest.fn(),
}));
jest.mock('components/Banner', () => 'Banner');
jest.mock('data/redux', () => ({
hooks: {
usePlatformSettingsData: jest.fn(),
useCardEntitlementsData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
let el;
const entitlementsData = {
isEntitlement: true,
hasSessions: true,
isFulfilled: false,
changeDeadline: 'test-deadline',
showExpirationWarning: false,
};
const platformData = { supportEmail: 'test-support-email' };
const render = (overrides = {}) => {
const { entitlements = {} } = overrides;
appHooks.useCardEntitlementsData.mockReturnValueOnce({ ...entitlementsData, ...entitlements });
appHooks.usePlatformSettingsData.mockReturnValueOnce(platformData);
el = shallow(<EntitlementBanner courseNumber={courseNumber} />);
};
describe('EntitlementBanner', () => {
beforeEach(() => {
useIntl.mockReturnValue({
formatDate: (date) => date,
formatMessage: (message, values) => <div {...{ message, values }} />,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('initializes data with course number from entitlements', () => {
render();
expect(appHooks.useCardEntitlementsData).toHaveBeenCalledWith(courseNumber);
});
test('no display if not an entitlement', () => {
render({ entitlements: { isEntitlement: false } });
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot: no sessions available', () => {
render({ entitlements: { isFulfilled: false, hasSessions: false } });
expect(el).toMatchSnapshot();
});
test('snapshot: expiration warning', () => {
render({ entitlements: { showExpirationWarning: true } });
expect(el).toMatchSnapshot();
});
test('no display if sessions available and not displaying warning', () => {
render();
expect(el.isEmptyRender()).toEqual(true);
});
});

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseBanner audit access expired, can upgrade snapshot: (auditAccessExpired, upgradeToAccess) 1`] = `
<Banner>
Your audit access to this course has expired.
Upgrade now to access your course again.
</Banner>
`;
exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = `
<Banner>
Your audit access to this course has expired.
<Hyperlink
destination=""
>
Find another course
</Hyperlink>
</Banner>
`;
exports[`CourseBanner course run active and cannot upgrade snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink) 1`] = `
<Banner>
Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.
<Hyperlink
destination="test-course-website"
>
Explore course details.
</Hyperlink>
</Banner>
`;

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseBanner audit access expired, can upgrade snapshot: (auditAccessExpired, upgradeToAccess) 1`] = `
<Banner>
Your audit access to this course has expired.
Upgrade now to access your course again.
</Banner>
`;
exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = `
<Banner>
Your audit access to this course has expired.
<Hyperlink
destination=""
>
Find another course
</Hyperlink>
</Banner>
`;
exports[`CourseBanner course run active and cannot upgrade snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink) 1`] = `
<Banner>
Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.
<Hyperlink
destination="test-course-website"
>
Explore course details.
</Hyperlink>
</Banner>
`;

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EntitlementBanner snapshot: expiration warning 1`] = `
<Banner>
<div
message={
Object {
"defaultMessage": "You must {selectSessionButton} by {changeDeadline} to access the course.",
"description": "Entitlements course message when the entitlement is expiring soon.",
"id": "learner-dash.courseCard.banners.entitlementsExpiringSoon",
}
}
values={
Object {
"changeDeadline": "test-deadline",
"selectSessionButton": <Button
className="m-0 p-0"
size="inline"
variant="link"
>
<div
message={
Object {
"defaultMessage": "select a session",
"description": "Entitlements session selection link text",
"id": "learner-dash.courseCard.banners.selectSession",
}
}
/>
</Button>,
}
}
/>
</Banner>
`;
exports[`EntitlementBanner snapshot: no sessions available 1`] = `
<Banner
variant="warning"
>
<div
message={
Object {
"defaultMessage": "There are no sessions available at the moment. The course team will create new sessions soon. If no sessions appear, please contact {emailLink} for information.",
"description": "Entitlements course message when no sessions are available",
"id": "learner-dash.courseCard.banners.entitlementsUnavailable",
}
}
values={
Object {
"emailLink": <MailtoLink
to="test-support-email"
>
test-support-email
</MailtoLink>,
}
}
/>
</Banner>
`;

View File

@@ -0,0 +1,96 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
auditAccessExpired: {
id: 'learner-dash.courseCard.banners.auditAccessExpired',
description: 'Audit access expiration banner message',
defaultMessage: 'Your audit access to this course has expired.',
},
upgradeToAccess: {
id: 'learner-dash.courseCard.banners.upgradeToAccess',
description: 'Upgrade prompt for audit-expired learners that can still upgrade',
defaultMessage: 'Upgrade now to access your course again.',
},
findAnotherCourse: {
id: 'learner-dash.courseCard.banners.findAnotherCourse',
description: 'Action prompt taking learners to course exploration',
defaultMessage: 'Find another course',
},
upgradeDeadlinePassed: {
id: 'learner-dash.courseCard.banners.upgradeDeadlinePassed',
description: 'Audit upgrade deadline passed banner message',
defaultMessage: 'Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.',
},
exploreCourseDetails: {
id: 'learner-dash.courseCard.banners.exploreCourseDetails',
description: 'Action prompt taking learners to course details page',
defaultMessage: 'Explore course details.',
},
certRestricted: {
id: 'learner-dash.courseCard.banners.certificateRestricted',
description: 'Restricted certificate warning message',
defaultMessage: 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {supportEmail}.',
},
certRefundContactBilling: {
id: 'learner-dash.courseCard.banners.certificateRefundContactBilling',
description: 'Message to learners to contact billing for certificate refunds',
defaultMessage: 'If you would like a refund on your Certificate of Achievement, please contact our billing address {billingEmail}',
},
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}',
},
notEligibleForCert: {
id: 'learner-dash.courseCard.banners.notEligibleForCert',
description: 'Certificate inelligibility message',
defaultMessage: 'You are not eligible for a certificate.',
},
viewGrades: {
id: 'learner-dash.courseCard.banners.viewGrades',
description: 'Gradses link text',
defaultMessage: 'View grades.',
},
certReady: {
id: 'learner-dash.courseCard.banners.certReady',
description: 'Certificate ready message',
defaultMessage: 'Congratulations. Your certificate is ready.',
},
viewCertificate: {
id: 'learner-dash.courseCard.banners.viewCertificate',
description: 'Certificate link text',
defaultMessage: 'View Certificate.',
},
certMinGrade: {
id: 'learner-dash.courseCard.banners.certMinGrade',
description: 'Passing grade requirement message',
defaultMessage: 'Grade required for a certificate: {minPassingGrade}',
},
downloadCertificate: {
id: 'learner-dash.courseCard.banners.downloadCertificate',
description: 'Certificate download link text',
defaultMessage: 'Download Certificate.',
},
gradeAndCertReadyAfter: {
id: 'learner-dash.courseCard.banners.gradseAndCertReadyAfter',
description: 'Grade and certificate availability date message',
defaultMessage: 'Your grade and certificate will be ready after {availableDate}.',
},
entitlementsUnavailable: {
id: 'learner-dash.courseCard.banners.entitlementsUnavailable',
description: 'Entitlements course message when no sessions are available',
defaultMessage: 'There are no sessions available at the moment. The course team will create new sessions soon. If no sessions appear, please contact {emailLink} for information.',
},
entitlementsExpiringSoon: {
id: 'learner-dash.courseCard.banners.entitlementsExpiringSoon',
description: 'Entitlements course message when the entitlement is expiring soon.',
defaultMessage: 'You must {selectSessionButton} by {changeDeadline} to access the course.',
},
selectSession: {
id: 'learner-dash.courseCard.banners.selectSession',
description: 'Entitlements session selection link text',
defaultMessage: 'select a session',
},
});
export default messages;

View File

@@ -1,37 +1,35 @@
import { Locked } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux';
import { useIntl, useCardValues } from 'hooks';
import { hooks as appHooks } from 'data/redux';
import messages from './messages';
const { cardData } = selectors;
export const useCardActionData = ({ courseNumber }) => {
const { formatMessage } = useIntl();
const data = useCardValues(courseNumber, {
canUpgrade: cardData.canUpgrade,
isAudit: cardData.isAudit,
isAuditAccessExpired: cardData.isAuditAccessExpired,
isVerified: cardData.isVerified,
isPending: cardData.isCourseRunPending,
isFinished: cardData.isCourseRunFinished,
});
const {
canUpgrade,
isAudit,
isAuditAccessExpired,
isVerified,
} = appHooks.useCardEnrollmentData(courseNumber);
const { isPending, isArchived } = appHooks.useCardCourseRunData(courseNumber);
let primary;
let secondary = null;
if (!data.isVerified) {
if (!isVerified) {
secondary = {
iconBefore: Locked,
variant: 'outline-primary',
disabled: !data.canUpgrade,
disabled: !canUpgrade,
children: formatMessage(messages.upgrade),
};
}
if (data.isPending) {
if (isPending) {
primary = { children: formatMessage(messages.beginCourse) };
} else if (!data.isFinished) {
} else if (!isArchived) {
primary = {
children: formatMessage(messages.resume),
disabled: data.isAudit && data.isAuditAccessExpired,
disabled: isAudit && isAuditAccessExpired,
};
} else {
primary = { children: formatMessage(messages.viewCourse) };

View File

@@ -1,54 +1,48 @@
import { Locked } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux';
import { hooks as appHooks } 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;
jest.mock('data/redux', () => ({
hooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
}));
const props = {
const courseNumber = 'my-test-course-number';
const enrollmentData = {
canUpgrade: false,
isAudit: true,
isAuditAccessExpired: false,
isVerified: false,
};
const courseRunData = {
isPending: false,
isFinished: false,
isArchived: false,
};
describe('CourseCardActions hooks', () => {
let out;
const { formatMessage } = appHooks.useIntl();
describe('data connection', () => {
beforeEach(() => {
out = hooks.useCardActionData({ courseNumber });
});
testCardValues(courseNumber, {
canUpgrade: fieldKeys.canUpgrade,
isAudit: fieldKeys.isAudit,
isAuditAccessExpired: fieldKeys.isAuditAccessExpired,
isVerified: fieldKeys.isVerified,
isPending: fieldKeys.isCourseRunPending,
isFinished: fieldKeys.isCourseRunFinished,
});
});
const { formatMessage } = useIntl();
const runHook = (overrides = {}) => {
const { enrollment = {}, courseRun = {} } = overrides;
appHooks.useCardCourseRunData.mockReturnValueOnce({ ...courseRunData, ...courseRun });
appHooks.useCardEnrollmentData.mockReturnValueOnce({ ...enrollmentData, ...enrollment });
out = hooks.useCardActionData({ courseNumber });
};
describe('secondary action', () => {
it('returns null if verified', () => {
appHooks.useCardValues.mockReturnValueOnce({
...props,
isAudit: false,
isVerified: true,
});
out = hooks.useCardActionData({ courseNumber });
runHook({ enrollment: { isAudit: false, isVerified: true } });
expect(out.secondary).toEqual(null);
});
it('returns disabled upgrade button if audit, but cannot upgrade', () => {
appHooks.useCardValues.mockReturnValueOnce(props);
out = hooks.useCardActionData({ courseNumber });
runHook();
expect(out.secondary).toEqual({
iconBefore: Locked,
variant: 'outline-primary',
@@ -57,8 +51,7 @@ describe('CourseCardActions hooks', () => {
});
});
it('returns enabled upgrade button if audit and can upgrade', () => {
appHooks.useCardValues.mockReturnValueOnce({ ...props, canUpgrade: true });
out = hooks.useCardActionData({ courseNumber });
runHook({ enrollment: { canUpgrade: true } });
expect(out.secondary).toEqual({
iconBefore: Locked,
variant: 'outline-primary',
@@ -69,37 +62,30 @@ describe('CourseCardActions hooks', () => {
});
describe('primary action', () => {
it('returns Begin Course button if pending', () => {
appHooks.useCardValues.mockReturnValueOnce({ ...props, isPending: true });
out = hooks.useCardActionData({ courseNumber });
expect(out.primary).toEqual({
children: formatMessage(messages.beginCourse),
});
runHook({ courseRun: { isPending: true } });
expect(out.primary).toEqual({ children: formatMessage(messages.beginCourse) });
});
it('returns enabled Resume button if active, and not audit with expired access', () => {
appHooks.useCardValues.mockReturnValueOnce({ ...props, isAuditAccessExpired: true });
out = hooks.useCardActionData({ courseNumber });
runHook({ enrollment: { isAuditAccessExpired: true } });
expect(out.primary).toEqual({
children: formatMessage(messages.resume),
disabled: true,
});
});
it('returns disabled Resume button if active and audit without expired access', () => {
appHooks.useCardValues.mockReturnValueOnce({ ...props });
out = hooks.useCardActionData({ courseNumber });
runHook();
expect(out.primary).toEqual({
children: formatMessage(messages.resume),
disabled: false,
});
appHooks.useCardValues.mockReturnValueOnce({ ...props, isAudit: false, isVerified: true });
out = hooks.useCardActionData({ courseNumber });
runHook({ enrollment: { isAudit: false, isVerified: true } });
expect(out.primary).toEqual({
children: formatMessage(messages.resume),
disabled: false,
});
});
it('returns viewCourse button if finished', () => {
appHooks.useCardValues.mockReturnValueOnce({ ...props, isFinished: true });
out = hooks.useCardActionData({ courseNumber });
it('returns viewCourse button if archived', () => {
runHook({ courseRun: { isArchived: true } });
expect(out.primary).toEqual({
children: formatMessage(messages.viewCourse),
});

View File

@@ -2,14 +2,11 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { selectors } from 'data/redux';
import { useCardValues } from 'hooks';
import { hooks as appHooks } from 'data/redux';
import messages from './messages';
import * as module from './hooks';
const { cardData } = selectors;
export const state = StrictDict({
isOpen: (val) => React.useState(val), // eslint-disable-line
});
@@ -17,10 +14,7 @@ export const state = StrictDict({
export const useRelatedProgramsBadgeData = ({ courseNumber }) => {
const [isOpen, setIsOpen] = module.state.isOpen(false);
const { formatMessage } = useIntl();
const { numPrograms } = useCardValues(courseNumber, {
numPrograms: cardData.numRelatedPrograms,
});
const numPrograms = appHooks.useCardRelatedProgramsData(courseNumber).length;
let programsMessage = '';
if (numPrograms) {
programsMessage = formatMessage(

View File

@@ -1,13 +1,16 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { MockUseState } from 'testUtils';
import * as appHooks from 'hooks';
import { selectors } from 'data/redux';
import { hooks as appHooks } from 'data/redux';
import * as hooks from './hooks';
import messages from './messages';
const { cardData } = selectors;
jest.mock('data/redux', () => ({
hooks: {
useCardRelatedProgramsData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
@@ -16,7 +19,7 @@ const numPrograms = 27;
const { formatMessage } = useIntl();
describe('EmailSettingsModal hooks', () => {
describe('RelatedProgramsBadge hooks', () => {
let out;
describe('state values', () => {
state.testGetter(state.keys.isOpen);
@@ -27,8 +30,8 @@ describe('EmailSettingsModal hooks', () => {
describe('useRelatedProgramsBadgeData', () => {
beforeEach(() => {
state.mock();
appHooks.useCardValues.mockReturnValueOnce({
numPrograms,
appHooks.useCardRelatedProgramsData.mockReturnValueOnce({
length: numPrograms,
});
out = hooks.useRelatedProgramsBadgeData({ courseNumber });
});
@@ -52,21 +55,16 @@ describe('EmailSettingsModal hooks', () => {
expect(out.isOpen).toEqual(state.stateVals.isOpen);
});
test('returns numPrograms from useCardValues call on numRelatedPrograms', () => {
expect(appHooks.useCardValues).toHaveBeenCalledWith(
courseNumber,
{ numPrograms: cardData.numRelatedPrograms },
);
test('forwards numPrograms from relatedPrograms.length for the courseNumber', () => {
expect(out.numPrograms).toEqual(numPrograms);
});
test('returns empty programsMessage if no programs', () => {
appHooks.useCardValues.mockReturnValueOnce({ numPrograms: 0 });
appHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 });
out = hooks.useRelatedProgramsBadgeData({ courseNumber });
expect(out.programsMessage).toEqual('');
});
test('returns badgeLabelSingular programsMessage if 1 programs', () => {
appHooks.useCardValues.mockReturnValueOnce({ numPrograms: 1 });
appHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 });
out = hooks.useRelatedProgramsBadgeData({ courseNumber });
expect(out.programsMessage).toEqual(formatMessage(
messages.badgeLabelSingular,

View File

@@ -1,46 +1,40 @@
import { selectors } from 'data/redux';
import { useIntl, useCardValues } from 'hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import * as module from './hooks';
import messages from './messages';
const { cardData } = selectors;
export const useAccessMessage = ({ courseNumber }) => {
const { formatMessage, formatDate } = useIntl();
const {
accessExpirationDate,
isAudit,
isAuditAccessExpired,
} = appHooks.useCardEnrollmentData(courseNumber);
const { isArchived, endDate } = appHooks.useCardCourseRunData(courseNumber);
const data = useCardValues(courseNumber, {
accessExpirationDate: cardData.courseRunAccessExpirationDate,
isAudit: cardData.isAudit,
isFinished: cardData.isCourseRunFinished,
isAuditAccessExpired: cardData.isAuditAccessExpired,
endDate: cardData.courseRunEndDate,
});
if (data.isAudit) {
if (isAudit) {
return formatMessage(
data.isAuditAccessExpired ? messages.accessExpired : messages.accessExpires,
{ accessExpirationDate: formatDate(data.accessExpirationDate) },
isAuditAccessExpired ? messages.accessExpired : messages.accessExpires,
{ accessExpirationDate: formatDate(accessExpirationDate) },
);
}
return formatMessage(
data.isFinished ? messages.courseEnded : messages.courseEnds,
{ endDate: formatDate(data.endDate) },
isArchived ? messages.courseEnded : messages.courseEnds,
{ endDate: formatDate(endDate) },
);
};
export const useCardData = ({ courseNumber }) => {
const { formatMessage } = useIntl();
const data = useCardValues(courseNumber, {
title: cardData.courseTitle,
bannerUrl: cardData.courseBannerUrl,
providerName: cardData.providerName,
});
const { title, bannerUrl } = appHooks.useCardCourseData(courseNumber);
const providerName = appHooks.useCardProviderData(courseNumber).name;
return {
title: data.title,
bannerUrl: data.bannerUrl,
providerName: data.providerName || formatMessage(messages.unknownProviderName),
title,
bannerUrl,
providerName: providerName || formatMessage(messages.unknownProviderName),
accessMessage: module.useAccessMessage({ courseNumber }),
formatMessage,
};

View File

@@ -1,12 +1,19 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { keyStore } from 'utils';
import { selectors } from 'data/redux';
import * as appHooks from 'hooks';
import { testCardValues } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import * as hooks from './hooks';
import messages from './messages';
const { fieldKeys } = selectors.cardData;
jest.mock('data/redux', () => ({
hooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardProviderData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
const useAccessMessage = 'test-access-message';
@@ -15,121 +22,115 @@ const hookKeys = keyStore(hooks);
describe('CourseCard hooks', () => {
let out;
const { formatMessage, formatDate } = appHooks.useIntl();
const { formatMessage, formatDate } = useIntl();
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCardData', () => {
const courseData = {
title: 'fake-title',
bannerUrl: 'my-banner-url',
};
const providerData = {
name: 'my-provider-name',
};
const runHook = ({ course = {}, provider = {} }) => {
jest.spyOn(hooks, hookKeys.useAccessMessage)
.mockImplementationOnce(mockAccessMessage);
appHooks.useCardCourseData.mockReturnValueOnce({
...courseData,
...course,
});
appHooks.useCardProviderData.mockReturnValueOnce({
...providerData,
...provider,
});
out = hooks.useCardData({ courseNumber });
};
beforeEach(() => {
jest.spyOn(hooks, hookKeys.useAccessMessage).mockImplementationOnce(mockAccessMessage);
out = hooks.useCardData({ courseNumber });
});
testCardValues(courseNumber, {
title: fieldKeys.courseTitle,
bannerUrl: fieldKeys.courseBannerUrl,
providerName: fieldKeys.providerName,
});
test('providerName returns Unknown message if not provided', () => {
appHooks.useCardValues.mockReturnValueOnce({
title: 'title',
bannerUrl: 'bannerUrl',
providerName: null,
});
jest.spyOn(hooks, hookKeys.useAccessMessage).mockImplementationOnce(mockAccessMessage);
out = hooks.useCardData({ courseNumber });
expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName));
});
describe('useAccessMessage', () => {
it('returns the output of useAccessMessage hook, passed courseNumber', () => {
expect(out.accessMessage).toEqual(mockAccessMessage({ courseNumber }));
});
runHook({});
});
it('forwards formatMessage from useIntl', () => {
expect(out.formatMessage).toEqual(formatMessage);
});
it('passes course title and banner URL form course data', () => {
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber);
expect(out.title).toEqual(courseData.title);
expect(out.bannerUrl).toEqual(courseData.bannerUrl);
});
it('forwards useAccessMessage output, called with courseNumber', () => {
expect(out.accessMessage).toEqual(mockAccessMessage({ courseNumber }));
});
it('forwards provider name if it exists, else formatted unknown provider name', () => {
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber);
expect(out.providerName).toEqual(providerData.name);
runHook({ provider: { name: '' } });
expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName));
});
});
describe('useAccessMessage', () => {
const accessExpirationDate = 'test-expiration-date';
const endDate = 'test-end-date';
beforeEach(() => {
appHooks.useCardValues.mockClear();
});
describe('loaded data', () => {
beforeEach(() => {
out = hooks.useAccessMessage({ courseNumber });
const enrollmentData = {
accessExpirationDate: 'test-expiration-date',
isAudit: false,
isAuditAccessExpired: false,
};
const courseRunData = {
isFinished: false,
endDate: 'test-end-date',
};
const runHook = ({ enrollment = {}, courseRun = {} }) => {
appHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
testCardValues(courseNumber, {
accessExpirationDate: fieldKeys.courseRunAccessExpirationDate,
isAudit: fieldKeys.isAudit,
isFinished: fieldKeys.isCourseRunFinished,
isAuditAccessExpired: fieldKeys.isAuditAccessExpired,
endDate: fieldKeys.courseRunEndDate,
appHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
out = hooks.useAccessMessage({ courseNumber });
};
it('loads data from enrollment and course run data based on course number', () => {
runHook({});
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber);
});
describe('if audit, and expired', () => {
it('returns accessExpired message with accessExpirationDate from cardData', () => {
appHooks.useCardValues.mockReturnValueOnce({
accessExpirationDate,
endDate,
isAudit: true,
isFinished: false,
isAuditAccessExpired: true,
});
expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage(
runHook({ enrollment: { isAudit: true, isAuditAccessExpired: true } });
expect(out).toEqual(formatMessage(
messages.accessExpired,
{ accessExpirationDate: formatDate(accessExpirationDate) },
{ accessExpirationDate: formatDate(enrollmentData.accessExpirationDate) },
));
});
});
describe('if audit and not expired', () => {
it('returns accessExpires message with accessExpirationDate from cardData', () => {
appHooks.useCardValues.mockReturnValueOnce({
accessExpirationDate,
endDate,
isAudit: true,
isFinished: false,
isAuditAccessExpired: false,
});
expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage(
runHook({ enrollment: { isAudit: true } });
expect(out).toEqual(formatMessage(
messages.accessExpires,
{ accessExpirationDate: formatDate(accessExpirationDate) },
{ accessExpirationDate: formatDate(enrollmentData.accessExpirationDate) },
));
});
});
describe('if verified and not ended', () => {
it('returns course ends message with course end date', () => {
appHooks.useCardValues.mockReturnValueOnce({
accessExpirationDate,
endDate,
isAudit: false,
isFinished: false,
isAuditAccessExpired: false,
});
expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage(
runHook({});
expect(out).toEqual(formatMessage(
messages.courseEnds,
{ endDate: formatDate(endDate) },
{ endDate: formatDate(courseRunData.endDate) },
));
});
});
describe('if verified and ended', () => {
it('returns course ended message with course end date', () => {
appHooks.useCardValues.mockReturnValueOnce({
accessExpirationDate,
endDate,
isAudit: false,
isFinished: true,
isAuditAccessExpired: false,
});
expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage(
runHook({ courseRun: { isArchived: true } });
expect(out).toEqual(formatMessage(
messages.courseEnded,
{ endDate: formatDate(endDate) },
{ endDate: formatDate(courseRunData.endDate) },
));
});
});

View File

@@ -16,38 +16,28 @@ exports[`EmailSettingsModal render snapshot: emails disabled, show: false 1`] =
}
>
<h4>
<formatMessage
msg="Receive course emails?"
/>
Receive course emails?
</h4>
<Form.Switch
checked={true}
onChange={[MockFunction hooks.onToggle]}
>
<formatMessage
msg="Course emails are on"
/>
Course emails are on
</Form.Switch>
<p>
<formatMessage
msg="Course emailsi include important information about your course."
/>
Course emailsi include important information about your course.
</p>
<ActionRow>
<Button
onClick={[MockFunction closeModal]}
variant="tertiary"
>
<formatMessage
msg="Nevermind"
/>
Nevermind
</Button>
<Button
onClick={[MockFunction hooks.save]}
>
<formatMessage
msg="Save settings"
/>
Save settings
</Button>
</ActionRow>
</div>
@@ -70,38 +60,28 @@ exports[`EmailSettingsModal render snapshot: emails disabled, show: true 1`] = `
}
>
<h4>
<formatMessage
msg="Receive course emails?"
/>
Receive course emails?
</h4>
<Form.Switch
checked={true}
onChange={[MockFunction hooks.onToggle]}
>
<formatMessage
msg="Course emails are on"
/>
Course emails are on
</Form.Switch>
<p>
<formatMessage
msg="Course emailsi include important information about your course."
/>
Course emailsi include important information about your course.
</p>
<ActionRow>
<Button
onClick={[MockFunction closeModal]}
variant="tertiary"
>
<formatMessage
msg="Nevermind"
/>
Nevermind
</Button>
<Button
onClick={[MockFunction hooks.save]}
>
<formatMessage
msg="Save settings"
/>
Save settings
</Button>
</ActionRow>
</div>
@@ -124,38 +104,28 @@ exports[`EmailSettingsModal render snapshot: emails enabled, show: true 1`] = `
}
>
<h4>
<formatMessage
msg="Receive course emails?"
/>
Receive course emails?
</h4>
<Form.Switch
checked={false}
onChange={[MockFunction hooks.onToggle]}
>
<formatMessage
msg="Course emails are off"
/>
Course emails are off
</Form.Switch>
<p>
<formatMessage
msg="Course emailsi include important information about your course."
/>
Course emailsi include important information about your course.
</p>
<ActionRow>
<Button
onClick={[MockFunction closeModal]}
variant="tertiary"
>
<formatMessage
msg="Nevermind"
/>
Nevermind
</Button>
<Button
onClick={[MockFunction hooks.save]}
>
<formatMessage
msg="Save settings"
/>
Save settings
</Button>
</ActionRow>
</div>

View File

@@ -2,13 +2,10 @@ import React from 'react';
import { StrictDict } from 'utils';
// import { thunkActions } from 'data/redux';
import { selectors } from 'data/redux';
import { useCardValues } from 'hooks';
import { hooks as appHooks } from 'data/redux';
import * as module from './hooks';
const { cardData } = selectors;
export const state = StrictDict({
toggle: (val) => React.useState(val), // eslint-disable-line
});
@@ -18,10 +15,8 @@ export const useEmailData = ({
courseNumber,
// dispatch,
}) => {
const data = useCardValues(courseNumber, {
isEnabled: cardData.isEmailEnabled,
});
const [toggleValue, setToggleValue] = module.state.toggle(data.isEnabled);
const { isEmailEnabled } = appHooks.useCardEnrollmentData(courseNumber);
const [toggleValue, setToggleValue] = module.state.toggle(isEmailEnabled);
const onToggle = React.useCallback(
() => setToggleValue(!toggleValue),
[setToggleValue, toggleValue],

View File

@@ -1,10 +1,13 @@
import { MockUseState, testCardValues } from 'testUtils';
import * as appHooks from 'hooks';
import { selectors } from 'data/redux';
import { MockUseState } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import * as hooks from './hooks';
const { fieldKeys } = selectors.cardData;
jest.mock('data/redux', () => ({
hooks: {
useCardEnrollmentData: jest.fn(),
},
}));
const courseNumber = 'my-test-course-number';
const closeModal = jest.fn();
@@ -22,18 +25,20 @@ describe('EmailSettingsModal hooks', () => {
describe('useEmailData', () => {
beforeEach(() => {
state.mock();
appHooks.useCardValues.mockReturnValueOnce({ isEnabled: true });
appHooks.useCardEnrollmentData.mockReturnValueOnce({ isEmailEnabled: true });
out = hooks.useEmailData({ closeModal, courseNumber });
});
afterEach(state.restore);
testCardValues(courseNumber, { isEnabled: fieldKeys.isEmailEnabled });
test('loads enrollment data based on course number', () => {
expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber);
});
test('initializes toggle value to cardData.isEmailEnabled', () => {
state.expectInitializedWith(state.keys.toggle, true);
expect(out.toggleValue).toEqual(true);
appHooks.useCardValues.mockReturnValueOnce({ isEnabled: false });
appHooks.useCardEnrollmentData.mockReturnValueOnce({ isEmailEnabled: false });
out = hooks.useEmailData({ closeModal, courseNumber });
state.expectInitializedWith(state.keys.toggle, false);
expect(out.toggleValue).toEqual(false);

View File

@@ -9,19 +9,13 @@ exports[`RelatedProgramsModal snapshot: closed 1`] = `
isOpen={false}
onClose={[MockFunction props.closeModal]}
size="lg"
title={
<formatMessage
msg="Related Programs"
/>
}
title="Related Programs"
>
<ModalDialog.Header
as="h3"
className="programs-title m-0 p-0"
>
<formatMessage
msg="Related Programs"
/>
Related Programs
</ModalDialog.Header>
<ModalDialog.Header
as="h4"
@@ -33,9 +27,7 @@ exports[`RelatedProgramsModal snapshot: closed 1`] = `
className="pl-0"
>
<p>
<formatMessage
msg="Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in"
/>
Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in
</p>
<CardGrid
columnSizes={
@@ -93,19 +85,13 @@ exports[`RelatedProgramsModal snapshot: open 1`] = `
isOpen={true}
onClose={[MockFunction props.closeModal]}
size="lg"
title={
<formatMessage
msg="Related Programs"
/>
}
title="Related Programs"
>
<ModalDialog.Header
as="h3"
className="programs-title m-0 p-0"
>
<formatMessage
msg="Related Programs"
/>
Related Programs
</ModalDialog.Header>
<ModalDialog.Header
as="h4"
@@ -117,9 +103,7 @@ exports[`RelatedProgramsModal snapshot: open 1`] = `
className="pl-0"
>
<p>
<formatMessage
msg="Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in"
/>
Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in
</p>
<CardGrid
columnSizes={

View File

@@ -54,15 +54,14 @@ export const ProgramCard = ({ data }) => {
};
ProgramCard.propTypes = {
data: PropTypes.shape({
estimatedNumberOfWeeks: PropTypes.number,
numberOfCourses: PropTypes.number,
bannerUrl: PropTypes.string,
estimatedNumberOfWeeks: PropTypes.number,
logoUrl: PropTypes.string,
title: PropTypes.string,
provider: PropTypes.string,
numberOfCourses: PropTypes.number,
programType: PropTypes.string,
programUrl: PropTypes.string,
programTypeUrl: PropTypes.string,
provider: PropTypes.string,
title: PropTypes.string,
}).isRequired,
};

View File

@@ -12,18 +12,10 @@ exports[`RelatedProgramsModal ProgramCard snapshot 1`] = `
>
<Card.ImageCap
className="program-card-banner"
logoAlt={
<formatMessage
msg="Provider logo"
/>
}
logoAlt="Provider logo"
logoSrc="props.data.logoUrl"
src="props.data.bannerUrl"
srcAlt={
<formatMessage
msg="Programm banner"
/>
}
srcAlt="Programm banner"
/>
<Card.Header
subtitle={
@@ -57,13 +49,9 @@ exports[`RelatedProgramsModal ProgramCard snapshot 1`] = `
<div
className="program-summary mt-2"
>
<formatMessage
msg="{numCourses} Courses"
/>
2 Courses
<formatMessage
msg="{numWeeks} Weeks"
/>
1 Weeks
</div>
</div>
</Card>

View File

@@ -1,27 +1,10 @@
import { selectors } from 'data/redux';
import { useCardValues } from 'hooks';
const { cardData } = selectors;
const { programs } = cardData;
import { hooks as appHooks } from 'data/redux';
export const useProgramData = ({
courseNumber,
}) => {
const data = useCardValues(courseNumber, {
courseTitle: cardData.courseTitle,
relatedPrograms: cardData.relatedPrograms,
});
return {
courseTitle: data.courseTitle,
relatedPrograms: data.relatedPrograms.map(program => ({
estimatedNumberOfWeeks: programs.estimatedNumberOfWeeks(program),
numberOfCourses: programs.numberOfCourses(program),
programType: programs.programType(program),
programTypeUrl: programs.programTypeUrl(program),
provider: programs.provider(program),
title: programs.title(program),
})),
};
};
}) => ({
courseTitle: appHooks.useCardCourseData(courseNumber).title,
relatedPrograms: appHooks.useCardRelatedProgramsData(courseNumber).list,
});
export default useProgramData;

View File

@@ -1,67 +1,26 @@
import { testCardValues } from 'testUtils';
import * as appHooks from 'hooks';
import { selectors } from 'data/redux';
import { hooks as appHooks } from 'data/redux';
import * as hooks from './hooks';
jest.mock('data/redux/cardData/selectors', () => ({
...jest.requireActual('data/redux/cardData/selectors'),
programs: {
estimatedNumberOfWeeks: (p) => p.estimatedNumberOfWeeks,
numberOfCourses: (p) => p.numberOfCourses,
programType: (p) => p.programType,
programTypeUrl: (p) => p.programTypeUrl,
provider: (p) => p.provider,
title: (p) => p.title,
jest.mock('data/redux', () => ({
hooks: {
useCardCourseData: jest.fn(),
useCardRelatedProgramsData: jest.fn(),
},
}));
const { fieldKeys } = selectors.cardData;
const courseNumber = 'test-course-number';
const courseTitle = 'test-course-title';
const relatedPrograms = [
{
estimatedNumberOfWeeks: 1,
numberOfCourses: 2,
programType: 'test-program-type-1',
programTypeUrl: 'test-program-type-1-url',
provider: 'test-provider-1',
title: 'test-program-title-1',
},
{
estimatedNumberOfWeeks: 2,
numberOfCourses: 3,
programType: 'test-program-type-2',
programTypeUrl: 'test-program-type-2-url',
provider: 'test-provider-2',
title: 'test-program-title-2',
},
{
estimatedNumberOfWeeks: 3,
numberOfCourses: 5,
programType: 'test-program-type-3',
programTypeUrl: 'test-program-type-3-url',
provider: 'test-provider-3',
title: 'test-program-title-3',
},
];
const relatedPrograms = ['some', 'programs'];
describe('RelatedProgramsModal hooks', () => {
let out;
beforeEach(() => {
appHooks.useCardValues.mockReturnValueOnce({ courseTitle, relatedPrograms });
out = hooks.useProgramData({ courseNumber });
});
testCardValues(courseNumber, {
courseTitle: fieldKeys.courseTitle,
relatedPrograms: fieldKeys.relatedPrograms,
});
test('courseTitle loads course title', () => {
expect(out.courseTitle).toEqual(courseTitle);
});
test('relatedPrograms loads from course run related programs', () => {
expect(out.relatedPrograms).toEqual(relatedPrograms);
it('forwards course title and related programs list by course number', () => {
appHooks.useCardCourseData.mockReturnValue({ title: courseTitle });
appHooks.useCardRelatedProgramsData.mockReturnValue({ list: relatedPrograms });
const out = hooks.useProgramData({ courseNumber });
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber);
expect(appHooks.useCardRelatedProgramsData).toHaveBeenCalledWith(courseNumber);
expect(out).toEqual({ courseTitle, relatedPrograms });
});
});

View File

@@ -40,7 +40,7 @@ export const RelatedProgramsModal = ({
columnSizes={{ lg: 6, xlg: 4, xs: 12 }}
>
{relatedPrograms.map((programData) => (
<ProgramCard key={`${programData.programUrl}`} data={programData} />
<ProgramCard key={programData.programUrl} data={programData} />
))}
</CardGrid>
</ModalDialog.Body>

View File

@@ -3,30 +3,22 @@
exports[`UnenrollConfirmModal ConfirmPane snapshot 1`] = `
<Fragment>
<h4>
<formatMessage
msg="Unenroll from course?"
/>
Unenroll from course?
</h4>
<p>
<formatMessage
msg="Progress that you've made so far will not be saved"
/>
Progress that you've made so far will not be saved
</p>
<ActionRow>
<Button
onClick={[MockFunction props.handleClose]}
variant="tertiary"
>
<formatMessage
msg="Nevermind"
/>
Nevermind
</Button>
<Button
onClick={[MockFunction props.handleConfirm]}
>
<formatMessage
msg="Unenroll"
/>
Unenroll
</Button>
</ActionRow>
</Fragment>

View File

@@ -3,22 +3,16 @@
exports[`UnenrollConfirmModal FinishedPane snapshot: did not give reason 1`] = `
<Fragment>
<h4>
<formatMessage
msg="You are unenrolled"
/>
You are unenrolled
</h4>
<p>
<formatMessage
msg="This course will be removed from your dashboard."
/>
This course will be removed from your dashboard.
</p>
<ActionRow>
<Button
onClick={[MockFunction props.handleClose]}
>
<formatMessage
msg="Return to dashboard"
/>
Return to dashboard
</Button>
</ActionRow>
</Fragment>
@@ -27,25 +21,17 @@ exports[`UnenrollConfirmModal FinishedPane snapshot: did not give reason 1`] = `
exports[`UnenrollConfirmModal FinishedPane snapshot: gave reason 1`] = `
<Fragment>
<h4>
<formatMessage
msg="You are unenrolled"
/>
You are unenrolled
</h4>
<p>
<formatMessage
msg="Thank you for sharing your reason for unenrolling. "
/>
<formatMessage
msg="This course will be removed from your dashboard."
/>
Thank you for sharing your reason for unenrolling.
This course will be removed from your dashboard.
</p>
<ActionRow>
<Button
onClick={[MockFunction props.handleClose]}
>
<formatMessage
msg="Return to dashboard"
/>
Return to dashboard
</Button>
</ActionRow>
</Fragment>

View File

@@ -3,9 +3,7 @@
exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = `
<Fragment>
<h4>
<formatMessage
msg="What's your main reason for unenrolling?"
/>
What's your main reason for unenrolling?
</h4>
<Form.RadioSet
name="unenrollReason"
@@ -16,84 +14,62 @@ exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = `
key="prereqs"
value="prereqs"
>
<formatMessage
msg="I don't have the academic or language prerequisites"
/>
I don't have the academic or language prerequisites
</Form.Radio>
<Form.Radio
key="difficulty"
value="difficulty"
>
<formatMessage
msg="The course material was too hard"
/>
The course material was too hard
</Form.Radio>
<Form.Radio
key="goals"
value="goals"
>
<formatMessage
msg="This won't help me reach my goals"
/>
This won't help me reach my goals
</Form.Radio>
<Form.Radio
key="broken"
value="broken"
>
<formatMessage
msg="Something was broken"
/>
Something was broken
</Form.Radio>
<Form.Radio
key="time"
value="time"
>
<formatMessage
msg="I don't have the time"
/>
I don't have the time
</Form.Radio>
<Form.Radio
key="browse"
value="browse"
>
<formatMessage
msg="I just wanted to browse the material"
/>
I just wanted to browse the material
</Form.Radio>
<Form.Radio
key="support"
value="support"
>
<formatMessage
msg="I don't have enough support"
/>
I don't have enough support
</Form.Radio>
<Form.Radio
key="quality"
value="quality"
>
<formatMessage
msg="I am not happy with the quality of the content"
/>
I am not happy with the quality of the content
</Form.Radio>
<Form.Radio
key="easy"
value="easy"
>
<formatMessage
msg="The course material was too easy"
/>
The course material was too easy
</Form.Radio>
<Form.Radio
value="custom"
>
<Form.Control
onChange={[MockFunction props.reason.customOption.onChange]}
placeholder={
<formatMessage
msg="Other"
/>
}
placeholder="Other"
value="props.reason.customOption.value"
/>
</Form.Radio>
@@ -103,16 +79,12 @@ exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = `
onClick={[MockFunction props.reason.skip]}
variant="tertiary"
>
<formatMessage
msg="Skip"
/>
Skip
</Button>
<Button
onClick={[MockFunction props.reason.submit]}
>
<formatMessage
msg="Submit"
/>
Submit
</Button>
</ActionRow>
</Fragment>

View File

@@ -5,6 +5,11 @@ const initialState = {
enrollments: [],
courseData: {},
entitlements: [],
emailConfirmation: {},
enterpriseDashboards: {},
platformSettings: {},
suggestedCourses: {},
filterState: {},
};
// eslint-disable-next-line no-unused-vars
@@ -12,18 +17,30 @@ const app = createSlice({
name: 'app',
initialState,
reducers: {
loadEnrollments: (state, { payload }) => ({
loadCourses: (state, { payload: { enrollments, entitlements } }) => ({
...state,
enrollments: payload.map(curr => curr.courseRun.courseNumber),
courseData: payload.reduce(
(obj, curr) => ({
...obj,
[curr.courseRun.courseNumber]: curr,
}),
{},
),
enrollments: [
...enrollments.map(curr => curr.courseRun.courseNumber),
...entitlements.map(curr => curr.courseRun.courseNumber),
],
courseData: {
...entitlements.reduce(
(obj, curr) => ({ ...obj, [curr.courseRun.courseNumber]: curr }),
{},
),
...enrollments.reduce(
(obj, curr) => ({ ...obj, [curr.courseRun.courseNumber]: curr }),
{},
),
},
}),
loadGlobalData: (state, { payload }) => ({
...state,
emailConfirmation: payload.emailConfirmation,
enterpriseDashboards: payload.enterpriseDashboards,
platformSettings: payload.platformSettings,
suggestedCourses: payload.suggestedCourses,
}),
loadEntitlements: (state, { payload }) => ({ ...state, entitlements: payload }),
},
});

View File

@@ -20,45 +20,66 @@ describe('app reducer', () => {
});
};
describe('action handlers', () => {
test('loadEntitlements loads entitlements from payload', () => {
testAction(
actions.loadEntitlements(testValue),
{ entitlements: testValue },
);
});
describe('loadEnrollments', () => {
const enrollments = [
describe('loadCourses', () => {
const courseIds = [
'course-1',
'course-2',
'course-3',
];
const courseData = {
[enrollments[0]]: {
courseRun: { courseNumber: enrollments[0] },
const entitlementIds = [
'entitlement-course-1',
'entitlement-course-2',
];
const enrollmentData = [
{
courseRun: { courseNumber: courseIds[0] },
course: 1,
some: 'data',
},
[enrollments[1]]: {
courseRun: { courseNumber: enrollments[1] },
{
courseRun: { courseNumber: courseIds[1] },
course: 2,
some: 'other data',
},
[enrollments[2]]: {
courseRun: { courseNumber: enrollments[2] },
{
courseRun: { courseNumber: courseIds[2] },
course: 3,
some: 'still different data',
},
};
const enrollmentData = enrollments.map(v => courseData[v]);
];
const entitlementData = [
{
courseRun: { courseNumber: entitlementIds[0] },
course: 4,
some: 'STILL different data',
},
{
courseRun: { courseNumber: entitlementIds[1] },
course: 5,
some: 'still DIFFERENT data',
},
];
let out;
beforeEach(() => {
out = reducer(testState, actions.loadEnrollments(enrollmentData));
out = reducer(testState, actions.loadCourses({
enrollments: enrollmentData,
entitlements: entitlementData,
}));
});
it('loads list of courseRun ids into enrollments field', () => {
expect(out.enrollments).toEqual(enrollments);
expect(out.enrollments).toEqual([
...courseIds,
...entitlementIds,
]);
});
it('loads object keyed by courseRun ids into courseData field', () => {
expect(out.courseData).toEqual(courseData);
expect(out.courseData).toEqual({
[courseIds[0]]: enrollmentData[0],
[courseIds[1]]: enrollmentData[1],
[courseIds[2]]: enrollmentData[2],
[entitlementIds[0]]: entitlementData[0],
[entitlementIds[1]]: entitlementData[1],
});
});
});
});

View File

@@ -13,13 +13,87 @@ export const simpleSelectors = {
enrollments: mkSimpleSelector(app => app.enrollments),
entitlements: mkSimpleSelector(app => app.entitlements),
courseData: mkSimpleSelector(app => app.courseData),
platformSettings: mkSimpleSelector(app => app.platformSettings),
emailConfirmation: mkSimpleSelector(app => app.emailConfirmation),
enterpriseDashboards: mkSimpleSelector(app => app.enterpriseDashboards),
};
export const courseCardData = (state, courseNumber) => (
module.simpleSelectors.courseData(state)[courseNumber]
);
const mkCardSelector = (sel) => (state, courseNumber) => (
sel(courseCardData(state, courseNumber))
);
const dateSixMonthsFromNow = new Date();
dateSixMonthsFromNow.setDate(dateSixMonthsFromNow.getDate() + 180);
export const courseCard = StrictDict({
certificates: mkCardSelector(({ certificates }) => ({
availableDate: certificates.availableDate,
certDownloadUrl: certificates.certDownloadUrl,
honorCertDownloadUrl: certificates.honorCertDownloadUrl,
certPreviewUrl: certificates.certPreviewUrl,
isDownloadable: certificates.isDownloadable,
isEarnedButUnavailable: certificates.isEarned && !certificates.isAvailable,
isRestricted: certificates.isRestricted,
})),
course: mkCardSelector(({ course }) => ({
bannerUrl: course.bannerUrl,
title: course.title,
website: course.website,
})),
courseRun: mkCardSelector(({ courseRun }) => ({
endDate: courseRun?.endDate,
isArchived: courseRun.isArchived,
isStarted: courseRun.isStarted,
isFinished: courseRun.isFinished,
minPassingGrade: courseRun.minPassingGrade,
})),
enrollment: mkCardSelector(({ enrollment }) => ({
accessExpirationDate: enrollment.accessExpirationDate,
canUpgrade: enrollment.canUpgrade,
hasStarted: enrollment.hasStarted,
isAudit: enrollment.isAudit,
isAuditAccessExpired: enrollment.isAuditAccessExpired,
isEmailEnabled: enrollment.isEmailEnabled,
isVerified: enrollment.isVerified,
lastEnrolled: enrollment.lastEnrollment,
isEnrolled: enrollment.isEnrolled,
})),
entitlements: mkCardSelector(({ entitlements }) => {
const deadline = new Date(entitlements.changeDeadline);
const showExpirationWarning = deadline > new Date() && deadline <= dateSixMonthsFromNow;
return {
canChange: entitlements.canChange,
entitlementSessions: entitlements.availableSessions,
isEntitlement: entitlements.isEntitlement,
isExpired: entitlements.isExpired,
isFulfilled: entitlements.isFulfilled,
hasSessions: entitlements.availableSessions?.length > 0,
changeDeadline: entitlements.changeDeadline,
showExpirationWarning,
};
}),
grades: mkCardSelector(({ grades }) => ({ isPassing: grades.isPassing })),
provider: mkCardSelector(({ provider }) => ({ name: provider?.name })),
relatedPrograms: mkCardSelector(({ relatedPrograms }) => ({
list: relatedPrograms.map(program => ({
bannerUrl: program.bannerUrl,
estimatedNumberOfWeeks: program.estimatedNumberOfWeeks,
logoUrl: program.logoUrl,
numberOfCourses: program.numberOfCourses,
programType: program.programType,
programUrl: program.programUrl,
provider: program.provider,
title: program.title,
})),
length: relatedPrograms.length,
})),
});
export default StrictDict({
...simpleSelectors,
courseCardData,
courseCard,
});

View File

@@ -1,2 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as selectors } from './selectors';

View File

@@ -1,67 +0,0 @@
import { keyStore, StrictDict } from 'utils';
import app from 'data/redux/app/selectors';
// import * as module from './selectors';
const mkCardSelector = (sel) => (state, courseNumber) => (
sel(app.courseCardData(state, courseNumber))
);
export const fieldSelectors = {
canChangeEntitlementSession: data => data.entitlements.canChange,
canUpgrade: data => data.enrollment.canUpgrade,
certAvailableDate: data => data.certificates.availableDate,
certDownloadUrl: data => data.certificates.downloadUrls?.download,
certPreviewUrl: data => data.certificates.downloadUrls?.preview,
courseBannerUrl: data => data.course.bannerUrl,
courseRunAccessExpirationDate: data => data.courseRun.accessExpirationDate,
courseRunEndDate: data => data.courseRun?.endDate,
courseTitle: data => data.course.title,
courseWebsite: data => data.course.website,
entitlementSessions: data => data.entitlements.availableSessions,
isAudit: data => data.enrollment.isAudit,
isAuditAccessExpired: data => data.enrollment.isAuditAccessExpired,
isCertDownloadable: data => data.certificates.isDownloadable,
isCertEarnedButUnavailable: ({ certificates: { isEarned, isAvailable } }) => (
isEarned && !isAvailable
),
isCourseRunPending: data => data.courseRun.isPending,
isCourseRunStarted: data => data.courseRun.isStarted,
isCourseRunFinished: data => data.courseRun.isFinished,
isEmailEnabled: data => data.enrollment.isEmailEnabled,
isEntitlement: data => data.entitlements.isEntitlement,
isEntitlementExpired: data => data.entitlements.isExpired,
isEntitlementFulfilled: data => data.entitlements.isFulfilled,
isVerified: data => data.enrollment.isVerified,
isRestricted: data => data.certificates.isRestricted,
isPassing: data => data.grades.isPassing,
minPassingGrade: data => data.courseRun.minPassingGrade,
providerName: data => data.provider?.name,
relatedPrograms: data => data.relatedPrograms,
numRelatedPrograms: data => data.relatedPrograms.length,
};
fieldSelectors.isCourseRunActive = data => (
fieldSelectors.isCourseRunStarted(data) && !fieldSelectors.isCourseRunFinished(data)
);
export const programs = StrictDict({
estimatedNumberOfWeeks: data => data.estimatedNumberOfWeeks,
numberOfCourses: data => data.numberOfCourses,
programType: data => data.programType,
programTypeUrl: data => data.programTypeUrl,
provider: data => data.provider,
title: data => data.title,
});
export const fieldKeys = keyStore(fieldSelectors);
export const cardSelectors = Object.keys(fieldSelectors).reduce(
(obj, key) => ({ ...obj, [key]: mkCardSelector(fieldSelectors[key]) }),
{},
);
export default StrictDict({
...cardSelectors,
programs,
fieldKeys,
});

View File

@@ -1,255 +0,0 @@
import app from 'data/redux/app/selectors';
import * as selectors from './selectors';
jest.mock('data/redux/app/selectors', () => ({
courseCardData: jest.fn(),
}));
const {
default: exported,
programs,
fieldKeys,
} = selectors;
const courseNumber = 'test-course-number';
const testState = { test: 'state' };
const testValue = 'my-test-value';
/**
* Test a field selector, both in basic definition and exported/connected card field
* @param {string} key - field keys to test
* @param {string} basicMessage - basic usage test message
* @param {obj} basicTest - { data, expected } - passed data and expected output.
* expected defaults to testValue as defined in this file.
* @param {object[]} [conditionTests] - (optional) extra tests for special conditions
* condition: <str> explanation of the condition
* data: <obj> data to be passed to the transform
* expected: <any> expected output
* message: <str> test message
*/
const testFieldSelector = (key, basicMessage, basicTest, conditionTests = []) => {
describe(`fieldSelector: ${key}`, () => {
describe('basic usage', () => {
const { data, expected = testValue } = basicTest;
test(basicMessage, () => {
expect(selectors.fieldSelectors[key](data)).toEqual(expected);
});
it('exports a card selector for the given key, binding to course card data', () => {
app.courseCardData.mockReturnValueOnce(data);
expect(exported[key](testState, courseNumber)).toEqual(expected);
expect(app.courseCardData).toHaveBeenCalledWith(testState, courseNumber);
});
});
conditionTests.forEach((conditionTest) => {
const { data, expected } = conditionTest;
describe(conditionTest.condition, () => {
test(conditionTest.message, () => {
expect(selectors.fieldSelectors[key](data)).toEqual(expected);
});
it('exports a card selector for the given key, binding to course card data', () => {
app.courseCardData.mockReturnValueOnce(data);
expect(exported[key](testState, courseNumber)).toEqual(expected);
expect(app.courseCardData).toHaveBeenCalledWith(testState, courseNumber);
});
});
});
});
};
describe('cardData selectors', () => {
describe('fieldSelectors', () => {
testFieldSelector(fieldKeys.canChangeEntitlementSession,
'returns the entitlements canChangeEntitlementsSession value',
{ data: { entitlements: { canChange: testValue } } });
testFieldSelector(fieldKeys.canUpgrade,
'returns the enrollment canUpgrade value',
{ data: { enrollment: { canUpgrade: testValue } } });
testFieldSelector(fieldKeys.certAvailableDate,
'returns the certificates availableDate value',
{ data: { certificates: { availableDate: testValue } } });
testFieldSelector(fieldKeys.certDownloadUrl,
'returns the certificates download url value',
{ data: { certificates: { downloadUrls: { download: testValue } } } },
[{
condition: 'if no download urls are provided for certificates',
data: { certificates: {} },
expected: undefined,
message: 'returns undefined',
}]);
testFieldSelector(fieldKeys.certPreviewUrl,
'returns the certificates preview url value',
{ data: { certificates: { downloadUrls: { preview: testValue } } } },
[{
condition: 'if no downloadUrls are provided for certificates',
data: { certificates: {} },
expected: undefined,
message: 'returns undefined',
}]);
testFieldSelector(fieldKeys.courseBannerUrl,
'returns the course banner url value',
{ data: { course: { bannerUrl: testValue } } });
testFieldSelector(fieldKeys.courseRunAccessExpirationDate,
'returns the course run access expiration date value',
{ data: { courseRun: { accessExpirationDate: testValue } } });
testFieldSelector(fieldKeys.courseRunEndDate,
'returns the course banner url value',
{ data: { courseRun: { endDate: testValue } } },
[{
condition: 'if course run is not defined',
data: {},
expected: undefined,
message: 'returns undefined if there is no course run data',
}]);
testFieldSelector(fieldKeys.courseTitle,
'returns the course title value',
{ data: { course: { title: testValue } } });
testFieldSelector(fieldKeys.courseWebsite,
'returns the course website value',
{ data: { course: { website: testValue } } });
testFieldSelector(fieldKeys.entitlementSessions,
'returns available entitlement sessions value',
{ data: { entitlements: { availableSessions: testValue } } });
testFieldSelector(fieldKeys.isAudit,
'returns enrollment isAudit value',
{ data: { enrollment: { isAudit: testValue } } });
testFieldSelector(fieldKeys.isAuditAccessExpired,
'returns enrollment isAudiAccessExpired value',
{ data: { enrollment: { isAuditAccessExpired: testValue } } });
testFieldSelector(fieldKeys.isCertDownloadable,
'returns certificates isDownloadable value',
{ data: { certificates: { isDownloadable: testValue } } });
testFieldSelector(fieldKeys.isCertEarnedButUnavailable,
'returns true if is certificate is earned but not available',
{
data: { certificates: { isEarned: true, isAvailable: false } },
expected: true,
},
[
{
condition: 'certificate is not earned',
data: { certificates: { isEarned: false, isAvailable: false } },
expected: false,
message: 'returns false',
},
{
condition: 'certificate is available',
data: { certificates: { isEarned: true, isAvailable: true } },
expected: false,
message: 'returns false',
},
]);
testFieldSelector(fieldKeys.isCourseRunPending,
'returns courseRun isPending value',
{ data: { courseRun: { isPending: testValue } } });
testFieldSelector(fieldKeys.isCourseRunStarted,
'returns courseRun isStarted value',
{ data: { courseRun: { isStarted: testValue } } });
testFieldSelector(fieldKeys.isCourseRunFinished,
'returns courseRun isFinished value',
{ data: { courseRun: { isFinished: testValue } } });
testFieldSelector(fieldKeys.isEmailEnabled,
'returns enrollment isEmailEnabled value',
{ data: { enrollment: { isEmailEnabled: testValue } } });
testFieldSelector(fieldKeys.isEntitlement,
'returns entitlements isEntitlement value',
{ data: { entitlements: { isEntitlement: testValue } } });
testFieldSelector(fieldKeys.isEntitlementExpired,
'returns entitlements isExpired value',
{ data: { entitlements: { isExpired: testValue } } });
testFieldSelector(fieldKeys.isEntitlementFulfilled,
'returns entitlements isFulfilled value',
{ data: { entitlements: { isFulfilled: testValue } } });
testFieldSelector(fieldKeys.isVerified,
'returns enrollments isVerified value',
{ data: { enrollment: { isVerified: testValue } } });
testFieldSelector(fieldKeys.isRestricted,
'returns certificates isRestricted value',
{ data: { certificates: { isRestricted: testValue } } });
testFieldSelector(fieldKeys.isPassing,
'returns grades isPassing value',
{ data: { grades: { isPassing: testValue } } });
testFieldSelector(fieldKeys.minPassingGrade,
'returns course run minPassingGrade value',
{ data: { courseRun: { minPassingGrade: testValue } } });
testFieldSelector(fieldKeys.providerName,
'returns provider name value',
{ data: { provider: { name: testValue } } },
[{
condition: 'provider is not known',
data: {},
expected: undefined,
message: 'returns undefined',
}]);
testFieldSelector(fieldKeys.relatedPrograms,
'returns relatedPrograms value',
{ data: { relatedPrograms: testValue } });
testFieldSelector(fieldKeys.isCourseRunActive,
'returns true if course run is started but not finished',
{
data: { courseRun: { isStarted: true, isFinished: false } },
expected: true,
},
[
{
condition: 'is not started',
data: { courseRun: { isStarted: false, isFinished: false } },
expected: false,
message: 'returns false',
}, {
condition: 'is finished',
data: { courseRun: { isStarted: true, isFinished: true } },
expected: false,
message: 'returns false',
}]);
});
describe('programs', () => {
test('estimatedNumberOfWeeks returns value from passed program data', () => {
expect(
programs.estimatedNumberOfWeeks({ estimatedNumberOfWeeks: testValue }),
).toEqual(testValue);
});
test('numberOfCourses returns value from passed program data', () => {
expect(programs.numberOfCourses({ numberOfCourses: testValue })).toEqual(testValue);
});
test('programType returns value from passed program data', () => {
expect(programs.programType({ programType: testValue })).toEqual(testValue);
});
test('programTypeUrl returns value from passed program data', () => {
expect(programs.programTypeUrl({ programTypeUrl: testValue })).toEqual(testValue);
});
test('provider returns value from passed program data', () => {
expect(programs.provider({ provider: testValue })).toEqual(testValue);
});
test('title returns value from passed program data', () => {
expect(programs.title({ title: testValue })).toEqual(testValue);
});
});
});

23
src/data/redux/hooks.js Normal file
View File

@@ -0,0 +1,23 @@
import { useSelector } from 'react-redux';
import appSelectors from './app/selectors';
const { courseCard } = appSelectors;
export const useEmailConfirmationData = () => useSelector(appSelectors.emailConfirmation);
export const useEnterpriseDashboardData = () => useSelector(appSelectors.enterpriseDashboards);
export const usePlatformSettingsData = () => useSelector(appSelectors.platformSettings);
// eslint-disable-next-line
export const useCourseCardData = (selector) => (courseNumber) => useSelector(
(state) => selector(state, courseNumber),
);
export const useCardCertificateData = useCourseCardData(courseCard.certificates);
export const useCardCourseData = useCourseCardData(courseCard.course);
export const useCardCourseRunData = useCourseCardData(courseCard.courseRun);
export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment);
export const useCardEntitlementsData = useCourseCardData(courseCard.entitlements);
export const useCardGradeData = useCourseCardData(courseCard.grades);
export const useCardProviderData = useCourseCardData(courseCard.provider);
export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPrograms);

View File

@@ -4,14 +4,14 @@ import { StrictDict } from 'utils';
import * as app from './app';
import * as requests from './requests';
import * as cardData from './cardData';
import * as hooks from './hooks';
export { default as thunkActions } from './thunkActions';
const modules = {
app,
requests,
cardData,
};
const moduleProps = (propName) => Object.keys(modules).reduce(
@@ -28,6 +28,6 @@ const actions = StrictDict(moduleProps('actions'));
const selectors = StrictDict(moduleProps('selectors'));
export { actions, selectors };
export { actions, selectors, hooks };
export default rootReducer;

View File

@@ -14,18 +14,18 @@ import requests from './requests';
*/
export const initialize = () => (dispatch) => (
dispatch(requests.initializeList({
onSuccess: (({ enrollments, entitlements }) => {
dispatch(actions.app.loadEnrollments(enrollments));
dispatch(actions.app.loadEntitlements(entitlements));
onSuccess: (({ enrollments, entitlements, ...globalData }) => {
dispatch(actions.app.loadCourses({ enrollments, entitlements }));
dispatch(actions.app.loadGlobalData(globalData));
}),
}))
);
export const refreshList = () => (dispatch) => (
dispatch(requests.initializeList({
onSuccess: (({ enrollments, entitlements }) => {
dispatch(actions.app.loadEnrollments(enrollments));
dispatch(actions.app.loadEntitlements(entitlements));
onSuccess: (({ enrollments, entitlements, ...globalData }) => {
dispatch(actions.app.loadCourses({ enrollments, entitlements }));
dispatch(actions.app.loadGlobalData(globalData));
}),
}))
);

View File

@@ -17,7 +17,8 @@ import {
*********************************************************************************/
const initializeList = () => Promise.resolve({
enrollments: fakeData.courseRunData,
entitlements: fakeData.entitlementCourses,
entitlements: fakeData.entitlementData,
...fakeData.globalData,
});
export default { initializeList };

View File

@@ -21,20 +21,19 @@ export const relatedPrograms = [
title: 'Relativity in Modern Mechanics',
programUrl: 'www.edx/my-program',
programType: 'MicroBachelors Program',
programTypeUrl: 'www.edx/my-program-type',
numberOfCourses: 3,
estimatedDuration: '4 weeks',
estimatedNumberOfWeeks: 4,
},
{
provider: 'University of Maryland',
bannerUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg',
logoUrl: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/b9dc96da-b3fc-45a6-b6b7-b8e12eb79335-ac60112330e3.png',
title: 'Pandering for Modern Professionals',
programUrl: 'www.edx/my-program',
programUrl: 'www.edx/my-program-2',
programType: 'MicroBachelors Program',
programTypeUrl: 'www.edx/my-program-type',
numberOfCourses: 3,
estimatedDuration: '4 weeks',
estimatedNumberOfWeeks: 4,
},
];
@@ -49,24 +48,52 @@ const logos = {
const pastDate = '11/11/2000';
const futureDate = '11/11/3030';
const soonDate = new Date();
soonDate.setDate(soonDate.getDate() + 60);
const soonDateStr = soonDate.toDateString();
const globalData = {
emailConfirmation: {
isNeeded: true,
sendEmailUrl: 'sendConfirmation@edx.org',
},
enterpriseDashboards: {
availableDashboards: [
{ label: 'edX', url: 'edx.org/edx-dashboard' },
{ label: 'harvard', url: 'edx.org/harvard-dashboard' },
],
mostRecentDashboard: { label: 'edX', url: 'edx.org/edx-dashboard' },
},
platformSettings: {
supportEmail: 'support@example.com',
billingEmail: 'billing@email.com',
courseSearchUrl: 'edx.com/course-search',
},
};
export const genCourseRunData = (data = {}) => ({
isPending: false,
isStarted: false,
isFinished: false,
isArchived: false,
accessExpirationDate: futureDate,
endDate: futureDate,
minPassingGrade: 70,
homeUrl: 'edx.com/courses/my-course-url/home',
marketingUrl: 'edx.com/courses/my-course-url/marketing',
progressUrl: 'edx.com/courses/my-course-url/progress',
unenrollUrl: 'edx.com/courses/my-course-url/unenroll',
resumeUrl: 'edx.com/courses/my-course-url/resume',
...data,
});
export const genEnrollmentData = (data = {}) => ({
isAudit: true,
isVerified: false,
accessExpirationDate: futureDate,
canUpgrade: data.verified ? null : true,
hasStarted: false,
isAudit: true,
isAuditAccessExpired: data.verified ? null : false,
isEmailEnabled: false,
isEnrolled: true,
isVerified: false,
lastEnrolled: pastDate,
...data,
});
@@ -76,7 +103,9 @@ export const genCertificateData = (data = {}) => ({
isAvailable: false,
isEarned: false,
isDownloadable: false,
downloadUrls: null, // { preview, download }
certPreviewUrl: 'edx.com/courses/my-course-url/cert-preview',
certDownloadUrl: 'edx.com/courses/my-course-url/cert-download',
honorCertDownloadUrl: 'edx.com/courses/my-course-url/honor-cert-download',
...data,
});
@@ -167,7 +196,7 @@ export const courseRuns = [
{
enrollment: genEnrollmentData({ isAudit: false, isVerified: true }),
grades: { isPassing: false },
courseRun: { isFinished: true, endDate: pastDate },
courseRun: { isArchived: true, endDate: pastDate },
certificates: genCertificateData(),
entitlements: { isEntitlement: false },
},
@@ -200,10 +229,8 @@ export const courseRuns = [
isAvailable: true,
isDownloadable: true,
availableDate: pastDate,
downloadUrls: {
preview: logos.edx,
download: logos.social,
},
certDownloadUrl: logos.social,
certPreviewUrl: logos.edx,
}),
entitlements: { isEntitlement: false },
},
@@ -217,9 +244,7 @@ export const courseRuns = [
isAvailable: true,
isDownloadable: true,
availableDate: pastDate,
downloadUrls: {
download: logos.social,
},
certDownloadUrl: logos.social,
}),
entitlements: { isEntitlement: false },
},
@@ -277,7 +302,6 @@ export const courseRuns = [
grades: { isPassing: true },
courseRun: {
isStarted: true,
isFinished: true,
isArchived: true,
endDate: pastDate,
},
@@ -296,7 +320,6 @@ export const courseRuns = [
export const entitlementCourses = [
{
course: { title: genCourseTitle(100) },
entitlements: {
isEntitlement: true,
availableSessions,
@@ -308,7 +331,17 @@ export const entitlementCourses = [
isExpired: false,
},
}, {
course: { title: genCourseTitle(101) },
entitlements: {
isEntitlement: true,
availableSessions,
isRefundable: true,
isFulfilled: false,
canViewCourse: false,
changeDeadline: soonDateStr,
canChange: true,
isExpired: false,
},
}, {
entitlements: {
isEntitlement: true,
availableSessions,
@@ -320,10 +353,9 @@ export const entitlementCourses = [
isExpired: false,
},
}, {
course: { title: genCourseTitle(102) },
entitlements: {
isEntitlement: true,
availableSessions,
availableSessions: [],
isRefundable: true,
isFulfilled: false,
canViewCourse: false,
@@ -364,12 +396,45 @@ export const courseRunData = courseRuns.map(
...data,
courseRun: genCourseRunData({ ...data.courseRun, courseNumber }),
...iteratedData[providerIndex],
credit: { isPurchased: false, requestStatus: null },
};
},
);
export const entitlementData = entitlementCourses.map(
(data, index) => {
const title = genCourseTitle(100 + index);
const courseNumber = genCourseID(100 + index);
const providerIndex = index % 3;
const iteratedData = [
{
provider: providers.edx,
course: { title, bannerUrl: logos.edx },
relatedPrograms,
},
{
provider: providers.mit,
course: { title, bannerUrl: logos.science },
relatedPrograms: [relatedPrograms[0]],
},
{
provider: null,
course: { title, bannerUrl: logos.social },
relatedPrograms: [],
},
];
return {
...data,
enrollment: genEnrollmentData(),
grades: { isPassing: true },
certificates: genCertificateData(),
courseRun: genCourseRunData({ ...data.courseRun, courseNumber }),
...iteratedData[providerIndex],
};
},
);
export default {
courseRunData,
entitlementCourses,
entitlementData,
globalData,
};

View File

@@ -1,19 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
export const useCardValue = (courseNumber, sel) => (
useSelector(state => sel(state, courseNumber))
);
export const useCardValues = (courseNumber, mapping) => (
Object.keys(mapping).reduce(
// eslint-disable-next-line
(obj, key) => ({ ...obj, [key]: useCardValue(courseNumber, mapping[key]) }),
{},
)
);
export const useValueCallback = (cb, prereqs = []) => (
React.useCallback(e => cb(e.target.value), prereqs) // eslint-disable-line
);
@@ -23,7 +10,6 @@ export const nullMethod = () => ({});
export { useIntl };
export default {
useCardValues,
useValueCallback,
nullMethod,
useIntl,

View File

@@ -18,19 +18,16 @@ jest.mock('react', () => ({
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const PropTypes = jest.requireActual('prop-types');
const { formatMessage } = jest.requireActual('./testUtils');
const formatDate = jest.fn(date => date).mockName('useIntl.formatDate');
return {
...i18n,
intlShape: PropTypes.shape({
formatMessage: PropTypes.func,
}),
useIntl: () => ({
formatMessage: (msg) => (
<formatMessage
msg={msg.defaultMessage}
{...(msg.values && { values: msg.values })}
/>
),
formatDate: jest.fn().mockName('useIntl.formatDate'),
formatMessage,
formatDate,
}),
IntlProvider: () => 'IntlProvider',
defineMessages: m => m,
@@ -89,6 +86,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Hyperlink: 'Hyperlink',
Icon: 'Icon',
IconButton: 'IconButton',
MailtoLink: 'MailtoLink',
ModalDialog: {
Header: 'ModalDialog.Header',
Body: 'ModalDialog.Body',
@@ -149,23 +147,7 @@ jest.mock('react-redux', () => {
};
});
jest.mock('hooks', () => {
const formatMessage = jest.fn((msg, values) => ({ formatted: { msg, values } }));
return {
...jest.requireActual('hooks'),
useIntl: () => ({
formatMessage,
formatDate: jest.fn((date) => ({ formatted: date })),
}),
useCardValues: jest.fn((courseNumber, mapping) => (
Object.keys(mapping).reduce(
(obj, key) => ({
...obj,
[key]: { selector: mapping[key], courseNumber },
}),
{},
)
)),
nullMethod: jest.fn().mockName('hooks.nullMethod'),
};
});
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
nullMethod: jest.fn().mockName('hooks.nullMethod'),
}));

View File

@@ -80,7 +80,8 @@ const mockApi = () => {
resolveFns.init = {
success: () => resolve({
enrollments: fakeData.courseRunData,
entitlements: fakeData.entitlementCourses,
entitlements: fakeData.entitlementData,
...fakeData.globalData,
}),
};
}));
@@ -147,11 +148,12 @@ describe('ESG app integration tests', () => {
courseData.course.title,
);
cardDetails = inspector.get.card.details(card);
[
courseData.provider.name,
courseNumber,
appMessages.withValues.CourseCard.accessExpires({
accessExpirationDate: courseData.courseRun.accessExpirationDate,
accessExpirationDate: courseData.enrollment.accessExpirationDate,
}),
].forEach(value => inspector.verifyTextIncludes(cardDetails, value));

View File

@@ -1,13 +1,7 @@
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
*/
@@ -195,23 +189,3 @@ export class MockUseState {
});
}
}
/**
* Test that useCardValues 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.useCardValues.mock.calls[0][0]).toEqual(courseNumber);
});
Object.keys(mapping).forEach(key => {
test(`loads ${key} from card data ${mapping[key]} selector`, () => {
[[, mapped]] = appHooks.useCardValues.mock.calls;
expect(mapped[key]).toEqual(cardData[mapping[key]]);
});
});
});
};