feat: credit banner (#96)

This commit is contained in:
Ben Warzeski
2022-12-19 13:25:02 -05:00
committed by GitHub
parent a21698e96a
commit 166c64a391
52 changed files with 1678 additions and 141 deletions

150
package-lock.json generated
View File

@@ -45,9 +45,7 @@
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"react-router-dom": "5.3.3",
"react-share": "^4.4.0",
"react-zendesk": "^0.1.13",
"redux": "4.1.1",
@@ -18629,6 +18627,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
"integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dependencies": {
"@babel/runtime": "^7.12.1",
"tiny-warning": "^1.0.3"
@@ -24800,11 +24799,11 @@
}
},
"node_modules/react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",
"integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.3.tgz",
"integrity": "sha512-mzQGUvS3bM84TnbtMYR8ZjKnuPJ71IjSzR+DE6UkUqvN4czWIqEs17yLL8xkAycv4ev0AiN+IGrWu88vJs/p2w==",
"dependencies": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
@@ -24820,15 +24819,15 @@
}
},
"node_modules/react-router-dom": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz",
"integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.3.tgz",
"integrity": "sha512-Ov0tGPMBgqmbu5CDmN++tv2HQ9HlWDuWIIqn4b88gjlAN5IHI+4ZUZRcpz9Hl0azFIwihbLDYw1OiHGRo7ZIng==",
"dependencies": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.0",
"react-router": "5.3.3",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
},
@@ -24849,63 +24848,6 @@
"value-equal": "^1.0.1"
}
},
"node_modules/react-router-redux": {
"version": "5.0.0-alpha.9",
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.9.tgz",
"integrity": "sha512-euSgNIANnRXr4GydIuwA7RZCefrLQzIw5WdXspS8NPYbV+FxrKSS9MKG7U9vb6vsKHONnA4VxrVNWfnMUnUQAw==",
"deprecated": "This project is no longer maintained.",
"dependencies": {
"history": "^4.7.2",
"prop-types": "^15.6.0",
"react-router": "^4.2.0"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/react-router-redux/node_modules/history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
},
"node_modules/react-router-redux/node_modules/hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
},
"node_modules/react-router-redux/node_modules/path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
"dependencies": {
"isarray": "0.0.1"
}
},
"node_modules/react-router-redux/node_modules/react-router": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz",
"integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==",
"dependencies": {
"history": "^4.7.2",
"hoist-non-react-statics": "^2.5.0",
"invariant": "^2.2.4",
"loose-envify": "^1.3.1",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.1",
"warning": "^4.0.1"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/react-router/node_modules/history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
@@ -48902,11 +48844,11 @@
}
},
"react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",
"integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.3.tgz",
"integrity": "sha512-mzQGUvS3bM84TnbtMYR8ZjKnuPJ71IjSzR+DE6UkUqvN4czWIqEs17yLL8xkAycv4ev0AiN+IGrWu88vJs/p2w==",
"requires": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
@@ -48942,15 +48884,15 @@
}
},
"react-router-dom": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz",
"integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.3.tgz",
"integrity": "sha512-Ov0tGPMBgqmbu5CDmN++tv2HQ9HlWDuWIIqn4b88gjlAN5IHI+4ZUZRcpz9Hl0azFIwihbLDYw1OiHGRo7ZIng==",
"requires": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.0",
"react-router": "5.3.3",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
},
@@ -48970,58 +48912,6 @@
}
}
},
"react-router-redux": {
"version": "5.0.0-alpha.9",
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.9.tgz",
"integrity": "sha512-euSgNIANnRXr4GydIuwA7RZCefrLQzIw5WdXspS8NPYbV+FxrKSS9MKG7U9vb6vsKHONnA4VxrVNWfnMUnUQAw==",
"requires": {
"history": "^4.7.2",
"prop-types": "^15.6.0",
"react-router": "^4.2.0"
},
"dependencies": {
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"requires": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
},
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
},
"path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
"requires": {
"isarray": "0.0.1"
}
},
"react-router": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz",
"integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==",
"requires": {
"history": "^4.7.2",
"hoist-non-react-statics": "^2.5.0",
"invariant": "^2.2.4",
"loose-envify": "^1.3.1",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.1",
"warning": "^4.0.1"
}
}
}
},
"react-share": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/react-share/-/react-share-4.4.0.tgz",

View File

@@ -63,9 +63,7 @@
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"react-router-dom": "5.3.3",
"react-share": "^4.4.0",
"react-zendesk": "^0.1.13",
"redux": "4.1.1",

View File

@@ -11,7 +11,7 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
@import "~@edx/frontend-component-footer/dist/_footer";
.alert .alert-icon {
.alert.alert-info .alert-icon {
color: black;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MailtoLink } from '@edx/paragon';
export const EmailLink = ({ address }) => {
if (!address) {
return null;
}
return (
<MailtoLink to={address}>{address}</MailtoLink>
);
};
EmailLink.defaultProps = { address: null };
EmailLink.propTypes = { address: PropTypes.string };
export default EmailLink;

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditBanner component render with error state snapshot 1`] = `
<Banner
variant="danger"
>
<p
className="credit-error-msg"
>
<format-message-function
message={
Object {
"defaultMessage": "An error occurred with this transaction. For help, contact {supportEmailLink}.",
"description": "",
"id": "learner-dash.courseCard.banners.credit.error",
}
}
values={
Object {
"supportEmailLink": <EmailLink
address="test-support-email"
/>,
}
}
/>
</p>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;
exports[`CreditBanner component render with no error state snapshot 1`] = `
<Banner>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;

View File

@@ -0,0 +1,40 @@
import { StrictDict } from 'utils';
import { hooks as appHooks } from 'data/redux';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
import MustRequestContent from './views/MustRequestContent';
import PendingContent from './views/PendingContent';
import RejectedContent from './views/RejectedContent';
export const statusComponents = StrictDict({
pending: PendingContent,
approved: ApprovedContent,
rejected: RejectedContent,
});
export const useCreditBannerData = (cardId) => {
const credit = appHooks.useCardCreditData(cardId);
const { supportEmail } = appHooks.usePlatformSettingsData();
if (!credit.isEligible) { return null; }
const { error, purchased, requestStatus } = credit;
let ContentComponent = EligibleContent;
if (purchased) {
if (requestStatus == null) {
ContentComponent = MustRequestContent;
} else if (Object.keys(statusComponents).includes(requestStatus)) {
ContentComponent = statusComponents[requestStatus];
}
// Current behavior is to show Elligible State if unknown request status is returned
}
return {
ContentComponent,
error,
supportEmail,
};
};
export default {
useCreditBannerData,
};

View File

@@ -0,0 +1,90 @@
import { keyStore } from 'utils';
import { hooks as appHooks } from 'data/redux';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
import MustRequestContent from './views/MustRequestContent';
import PendingContent from './views/PendingContent';
import RejectedContent from './views/RejectedContent';
import * as hooks from './hooks';
jest.mock('data/redux', () => ({
hooks: {
useCardCreditData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
jest.mock('./views/ApprovedContent', () => 'ApprovedContent');
jest.mock('./views/EligibleContent', () => 'EligibleContent');
jest.mock('./views/MustRequestContent', () => 'MustRequestContent');
jest.mock('./views/PendingContent', () => 'PendingContent');
jest.mock('./views/RejectedContent', () => 'RejectedContent');
const cardId = 'test-card-id';
const statuses = keyStore(hooks.statusComponents);
const supportEmail = 'test-support-email';
let out;
const defaultProps = {
isEligible: true,
error: false,
isPurchased: false,
requestStatus: null,
};
const loadHook = (creditData = {}) => {
appHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData });
out = hooks.useCreditBannerData(cardId);
};
describe('useCreditBannerData hook', () => {
beforeEach(() => {
appHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
});
it('loads card credit data with cardID and loads platform settings data', () => {
loadHook({ isEligible: false });
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
expect(appHooks.usePlatformSettingsData).toHaveBeenCalledWith();
});
describe('non-credit-eligible learner', () => {
it('returns null if the learner is not credit eligible', () => {
loadHook({ isEligible: false });
expect(out).toEqual(null);
});
});
describe('credit-eligible learner', () => {
it('returns error object from credit', () => {
loadHook();
expect(out.error).toEqual(defaultProps.error);
loadHook({ error: true });
expect(out.error).toEqual(true);
});
describe('ContentComponent', () => {
it('returns EligibleContent if not purchased', () => {
loadHook();
expect(out.ContentComponent).toEqual(EligibleContent);
});
it('returns MustRequestContent if purchased but not requested', () => {
loadHook({ purchased: true });
expect(out.ContentComponent).toEqual(MustRequestContent);
});
it('returns PendingContent if purchased and request is pending', () => {
loadHook({ purchased: true, requestStatus: statuses.pending });
expect(out.ContentComponent).toEqual(PendingContent);
});
it('returns ApprovedContent if purchased and request is approved', () => {
loadHook({ purchased: true, requestStatus: statuses.approved });
expect(out.ContentComponent).toEqual(ApprovedContent);
});
it('returns RejectedContent if purchased and request is rejected', () => {
loadHook({ purchased: true, requestStatus: statuses.rejected });
expect(out.ContentComponent).toEqual(RejectedContent);
});
it('returns EligibleContent if purchased and request status is invalid', () => {
loadHook({ purchased: true, requestStatus: 'fake-status' });
expect(out.ContentComponent).toEqual(EligibleContent);
});
});
});
});

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import Banner from 'components/Banner';
import EmailLink from 'components/EmailLink';
import hooks from './hooks';
import messages from './messages';
export const CreditBanner = ({ cardId }) => {
const { formatMessage } = useIntl();
const hookData = hooks.useCreditBannerData(cardId);
if (hookData === null) {
return null;
}
const { ContentComponent, error, supportEmail } = hookData;
const supportEmailLink = (<EmailLink address={supportEmail} />);
return (
<Banner {...(error && { variant: 'danger' })}>
{error && (
<p className="credit-error-msg">
{formatMessage(messages.error, { supportEmailLink })}
</p>
)}
<ContentComponent cardId={cardId} />
</Banner>
);
};
CreditBanner.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CreditBanner;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import EmailLink from 'components/EmailLink';
import hooks from './hooks';
import messages from './messages';
import CreditBanner from '.';
jest.mock('components/Banner', () => 'Banner');
jest.mock('components/EmailLink', () => 'EmailLink');
jest.mock('./hooks', () => ({
useCreditBannerData: jest.fn(),
}));
let el;
const cardId = 'test-card-id';
const ContentComponent = () => 'ContentComponent';
const supportEmail = 'test-support-email';
describe('CreditBanner component', () => {
describe('behavior', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue(null);
el = shallow(<CreditBanner cardId={cardId} />);
});
it('initializes hooks with cardId', () => {
expect(hooks.useCreditBannerData).toHaveBeenCalledWith(cardId);
});
it('returns null if hookData is null', () => {
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('render', () => {
describe('with error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: true,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes danger variant to Banner parent', () => {
expect(el.find('Banner').props().variant).toEqual('danger');
});
it('includes credit-error-msg with support email link', () => {
expect(el.find('.credit-error-msg').containsMatchingElement(
formatMessage(messages.error, {
supportEmailLink: (<EmailLink address={supportEmail} />),
}),
)).toEqual(true);
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
describe('with no error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: false,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
});
});

View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
error: {
id: 'learner-dash.courseCard.banners.credit.error',
description: '',
defaultMessage: 'An error occurred with this transaction. For help, contact {supportEmailLink}.',
},
});
export default messages;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import messages from './messages';
export const ApprovedContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = appHooks.useCardCreditData(cardId);
const { formatMessage } = useIntl();
return (
<CreditContent
action={{ href, message: formatMessage(messages.viewCredit) }}
message={formatMessage(
messages.approved,
{
congratulations: <b>{formatMessage(messages.congratulations)}</b>,
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName,
},
)}
/>
);
};
ApprovedContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default ApprovedContent;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import ApprovedContent from './ApprovedContent';
jest.mock('data/redux', () => ({
hooks: {
useCardCreditData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
appHooks.useCardCreditData.mockReturnValue(credit);
describe('ApprovedContent component', () => {
beforeEach(() => {
el = shallow(<ApprovedContent cardId={cardId} />);
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
let component;
beforeAll(() => {
component = el.find('CreditContent');
});
test('action.href from credit.providerStatusUrl', () => {
expect(component.props().action.href).toEqual(credit.providerStatusUrl);
});
test('action.message is formatted viewCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.viewCredit));
});
test('message is formatted approved message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.approved,
{
congratulations: (<b>{formatMessage(messages.congratulations)}</b>),
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import track from 'tracking';
import CreditContent from './components/CreditContent';
import messages from './messages';
export const EligibleContent = ({ cardId }) => {
const { formatMessage } = useIntl();
const { providerName, creditPurchaseUrl: href } = appHooks.useCardCreditData(cardId);
const { courseId } = appHooks.useCardCourseRunData(cardId);
const onClick = track.credit.purchase(courseId, href);
const getCredit = formatMessage(messages.getCredit);
const message = providerName
? formatMessage(messages.eligibleFromProvider, { providerName })
: formatMessage(messages.eligible, { getCredit: (<b>{getCredit}</b>) });
return (
<CreditContent
action={{ onClick, message: getCredit }}
message={message}
/>
);
};
EligibleContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default EligibleContent;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { shallow } from 'enzyme';
import { hooks as appHooks } from 'data/redux';
import { formatMessage } from 'testUtils';
import track from 'tracking';
import messages from './messages';
import EligibleContent from './EligibleContent';
jest.mock('data/redux', () => ({
hooks: {
useCardCreditData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('tracking', () => ({
credit: {
purchase: (...args) => ({ trackCredit: args }),
},
}));
let el;
let component;
const cardId = 'test-card-id';
const courseId = 'test-course-id';
const credit = {
creditPurchaseUrl: 'test-credit-purchase-url',
providerName: 'test-credit-provider-name',
};
appHooks.useCardCreditData.mockReturnValue(credit);
appHooks.useCardCourseRunData.mockReturnValue({ courseId });
const render = () => {
el = shallow(<EligibleContent cardId={cardId} />);
};
const loadComponent = () => {
component = el.find('CreditContent');
};
describe('EligibleContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
it('initializes course run data with cardId', () => {
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
loadComponent();
});
test('action.onClick sends credit purchase track event', () => {
expect(component.props().action.onClick).toEqual(
track.credit.purchase(courseId, credit.creditPurchaseUrl),
);
});
test('action.message is formatted getCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.getCredit));
});
test('message is formatted eligible message if no provider', () => {
appHooks.useCardCreditData.mockReturnValueOnce({
creditPurchaseUrl: credit.creditPurchaseUrl,
});
render();
loadComponent();
expect(component.props().message).toEqual(formatMessage(
messages.eligible,
{ getCredit: (<b>{formatMessage(messages.getCredit)}</b>) },
));
});
test('message is formatted eligible message if provider', () => {
expect(component.props().message).toEqual(
formatMessage(messages.eligibleFromProvider, { providerName: credit.providerName }),
);
});
});
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import hooks from './hooks';
import messages from './messages';
export const MustRequestContent = ({ cardId }) => {
const { formatMessage } = useIntl();
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
return (
<CreditContent
action={{
message: formatMessage(messages.requestCredit),
onClick: createCreditRequest,
}}
message={formatMessage(messages.mustRequest, {
linkToProviderSite: (<ProviderLink cardId={cardId} />),
requestCredit: (<b>{formatMessage(messages.requestCredit)}</b>),
})}
requestData={requestData}
/>
);
};
MustRequestContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default MustRequestContent;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import messages from './messages';
import hooks from './hooks';
import ProviderLink from './components/ProviderLink';
import MustRequestContent from './MustRequestContent';
jest.mock('./hooks', () => ({
useCreditRequestData: jest.fn(),
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const requestData = { test: 'requestData' };
const createCreditRequest = jest.fn().mockName('createCreditRequest');
hooks.useCreditRequestData.mockReturnValue({ requestData, createCreditRequest });
const render = () => {
el = shallow(<MustRequestContent cardId={cardId} />);
};
describe('MustRequestContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes credit request data with cardId', () => {
expect(hooks.useCreditRequestData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
component = el.find('CreditContent');
});
test('action.onClick calls createCreditRequest from useCreditRequestData hook', () => {
expect(component.props().action.onClick).toEqual(createCreditRequest);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.requestCredit));
});
test('message is formatted mustRequest message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.mustRequest, {
linkToProviderSite: (<ProviderLink cardId={cardId} />),
requestCredit: (<b>{formatMessage(messages.requestCredit)}</b>),
}),
);
});
test('requestData drawn from useCreditRequestData hook', () => {
expect(component.props().requestData).toEqual(requestData);
});
});
});
});

View File

@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import CreditContent from './components/CreditContent';
import messages from './messages';
import hooks from './hooks';
export const PendingContent = ({ cardId }) => {
const { providerName } = appHooks.useCardCreditData(cardId);
const { formatMessage } = useIntl();
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
return (
<>
<CreditContent
action={{
onClick: createCreditRequest,
message: formatMessage(messages.viewDetails),
}}
message={formatMessage(messages.received, { providerName })}
requestData={requestData}
/>
</>
);
};
PendingContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default PendingContent;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import messages from './messages';
import hooks from './hooks';
import PendingContent from './PendingContent';
jest.mock('data/redux', () => ({ hooks: { useCardCreditData: jest.fn() } }));
jest.mock('./hooks', () => ({ useCreditRequestData: jest.fn() }));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const requestData = { test: 'requestData' };
const providerName = 'test-credit-provider-name';
const createCreditRequest = jest.fn().mockName('createCreditRequest');
appHooks.useCardCreditData.mockReturnValue({ providerName });
hooks.useCreditRequestData.mockReturnValue({ requestData, createCreditRequest });
const render = () => {
el = shallow(<PendingContent cardId={cardId} />);
};
describe('PendingContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes card credit data with cardId', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
it('initializes credit request data with cardId', () => {
expect(hooks.useCreditRequestData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
component = el.find('CreditContent');
});
test('action.onClick calls createCreditRequest from useCreditRequestData hook', () => {
expect(component.props().action.onClick).toEqual(createCreditRequest);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.viewDetails));
});
test('message is formatted pending message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.received, { providerName }),
);
});
test('requestData drawn from useCreditRequestData hook', () => {
expect(component.props().requestData).toEqual(requestData);
});
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import messages from './messages';
export const RejectedContent = ({ cardId }) => {
const credit = appHooks.useCardCreditData(cardId);
const { formatMessage } = useIntl();
return (
<CreditContent
message={formatMessage(messages.rejected, {
providerName: credit.providerName,
linkToProviderSite: (<ProviderLink cardId={cardId} />),
})}
/>
);
};
RejectedContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default RejectedContent;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import RejectedContent from './RejectedContent';
jest.mock('data/redux', () => ({
hooks: {
useCardCreditData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
appHooks.useCardCreditData.mockReturnValue(credit);
let el;
let component;
const render = () => { el = shallow(<RejectedContent cardId={cardId} />); };
const loadComponent = () => { component = el.find('CreditContent'); };
describe('RejectedContent component', () => {
beforeEach(render);
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeAll(loadComponent);
test('no action is passed', () => {
expect(component.props().action).toEqual(undefined);
});
test('message is formatted rejected message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.rejected,
{
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});
});

View File

@@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button } from '@edx/paragon';
import CreditRequestForm from './CreditRequestForm';
export const CreditContent = ({ action, message, requestData }) => (
<>
<div className="message-copy credit-msg">
{message}
</div>
{action && (
<ActionRow className="mt-4">
<Button
as="a"
href={action.href}
rel="noopener"
target="_blank"
variant="outline-primary"
className="border-gray-400"
onClick={action.onClick}
>
{action.message}
</Button>
</ActionRow>
)}
<CreditRequestForm requestData={requestData} />
</>
);
CreditContent.defaultProps = {
action: null,
requestData: null,
};
CreditContent.propTypes = {
action: PropTypes.shape({
href: PropTypes.string,
onClick: PropTypes.func,
message: PropTypes.string,
}),
message: PropTypes.node.isRequired,
requestData: PropTypes.shape({
url: PropTypes.string,
parameters: PropTypes.objectOf(PropTypes.string),
}),
};
export default CreditContent;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { shallow } from 'enzyme';
import CreditContent from './CreditContent';
let el;
const action = {
href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'),
message: 'test-action-message',
};
const message = 'test-message';
const requestData = { url: 'test-request-data-url', parameters: { key1: 'val1' } };
const props = { action, message, requestData };
describe('CreditContent component', () => {
describe('render', () => {
describe('with action', () => {
beforeEach(() => {
el = shallow(<CreditContent {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads href, onClick, and message into action row button', () => {
const buttonEl = el.find('ActionRow Button');
expect(buttonEl.props().href).toEqual(action.href);
expect(buttonEl.props().onClick).toEqual(action.onClick);
expect(buttonEl.text()).toEqual(action.message);
});
it('loads message into credit-msg div', () => {
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
});
describe('without action', () => {
test('snapshot', () => {
el = shallow(<CreditContent {...{ message, requestData }} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads message into credit-msg div', () => {
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
});
});
});

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditRequestForm component render output valid requestData snapshot 1`] = `
<Form
accept-method="UTF-8"
action="test-request-data-url"
className="hidden"
method="POST"
>
<FormControl
as="textarea"
key="key1"
name="key1"
value="val1"
/>
<FormControl
as="textarea"
key="key2"
name="key2"
value="val2"
/>
<FormControl
as="textarea"
key="key3"
name="key3"
value="val3"
/>
<Button
type="submit"
/>
</Form>
`;

View File

@@ -0,0 +1,13 @@
import React from 'react';
export const useCreditRequestFormData = (requestData) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (requestData !== null) {
ref.current.click();
}
}, [requestData]);
return { ref };
};
export default useCreditRequestFormData;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import useCreditRequestFormData from './hooks';
const requestData = 'test-request-data';
let out;
const ref = {
current: { click: jest.fn() },
};
React.useRef.mockReturnValue(ref);
describe('useCreditRequestFormData hook', () => {
describe('behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes ref with null', () => {
useCreditRequestFormData(requestData);
expect(React.useRef).toHaveBeenCalledWith(null);
});
let cb;
let prereqs;
it('does not click current ref when request data changes and is null', () => {
useCreditRequestFormData(null);
([[cb, prereqs]] = React.useEffect.mock.calls);
expect(prereqs).toEqual([null]);
cb();
expect(ref.current.click).not.toHaveBeenCalled();
});
it('clicks current ref when request data changes and is not null', () => {
useCreditRequestFormData(requestData);
([[cb, prereqs]] = React.useEffect.mock.calls);
expect(prereqs).toEqual([requestData]);
cb();
expect(ref.current.click).toHaveBeenCalled();
});
});
describe('output', () => {
it('returns ref for submit button', () => {
out = useCreditRequestFormData(requestData);
expect(out.ref).toEqual(ref);
});
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Form, FormControl } from '@edx/paragon';
import useCreditRequestFormData from './hooks';
export const CreditRequestForm = ({ requestData }) => {
const { ref } = useCreditRequestFormData(requestData);
if (requestData === null) {
return null;
}
const { parameters, url } = requestData;
return (
<Form
accept-method="UTF-8"
action={url}
className="hidden"
method="POST"
>
{Object.keys(parameters).map((key) => (
<FormControl
as="textarea"
key={key}
name={key}
value={parameters[key]}
/>
))}
<Button type="submit" ref={ref} />
</Form>
);
};
CreditRequestForm.defaultProps = {
requestData: null,
};
CreditRequestForm.propTypes = {
requestData: PropTypes.shape({
parameters: PropTypes.objectOf(PropTypes.string),
url: PropTypes.string,
}),
};
export default CreditRequestForm;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { shallow } from 'enzyme';
import { keyStore } from 'utils';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),
}));
const ref = 'test-ref';
const requestData = {
url: 'test-request-data-url',
parameters: {
key1: 'val1',
key2: 'val2',
key3: 'val3',
},
};
const paramKeys = keyStore(requestData.parameters);
useCreditRequestFormData.mockReturnValue({ ref });
let el;
const shallowRender = (data) => { el = shallow(<CreditRequestForm requestData={data} />); };
describe('CreditRequestForm component', () => {
describe('behavior', () => {
it('initializes ref from hook with requestData', () => {
shallowRender(requestData);
expect(useCreditRequestFormData).toHaveBeenCalledWith(requestData);
});
});
describe('render output', () => {
describe('null requestData', () => {
it('returns null', () => {
shallowRender(null);
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('valid requestData', () => {
beforeEach(() => {
shallowRender(requestData);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads Form with requestData url', () => {
expect(el.find('Form').props().action).toEqual(requestData.url);
});
it('loads a textarea form control for each requestData parameter', () => {
const controls = el.find('FormControl');
expect(controls.at(0).props().name).toEqual(paramKeys.key1);
expect(controls.at(0).props().value).toEqual(requestData.parameters.key1);
expect(controls.at(1).props().name).toEqual(paramKeys.key2);
expect(controls.at(1).props().value).toEqual(requestData.parameters.key2);
expect(controls.at(2).props().name).toEqual(paramKeys.key3);
expect(controls.at(2).props().value).toEqual(requestData.parameters.key3);
});
});
});
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { render } from '@testing-library/react';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.unmock('@edx/paragon');
jest.unmock('react');
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),
}));
const ref = { current: { click: jest.fn() }, useRef: jest.fn() };
const requestData = {
url: 'test-request-data-url',
parameters: {
key1: 'val1',
key2: 'val2',
key3: 'val3',
},
};
useCreditRequestFormData.mockReturnValue({ ref });
let el;
describe('CreditRequestForm component ref behavior', () => {
it('loads submit button with ref from hook', () => {
el = render(<CreditRequestForm requestData={requestData} />);
const button = el.getByRole('button');
expect(ref.current).toEqual(button);
});
});

View File

@@ -0,0 +1,24 @@
/* eslint-disable max-len */
import React from 'react';
import PropTypes from 'prop-types';
import { hooks as appHooks } from 'data/redux';
import { Hyperlink } from '@edx/paragon';
export const ProviderLink = ({ cardId }) => {
const credit = appHooks.useCardCreditData(cardId);
return (
<Hyperlink
href={credit.providerStatusUrl}
rel="noopener"
target="_blank"
>
{credit.providerName}
</Hyperlink>
);
};
ProviderLink.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default ProviderLink;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import { hooks as appHooks } from 'data/redux';
import ProviderLink from './ProviderLink';
jest.mock('data/redux', () => ({
hooks: {
useCardCreditData: jest.fn(),
},
}));
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
let el;
describe('ProviderLink component', () => {
beforeEach(() => {
appHooks.useCardCreditData.mockReturnValue(credit);
el = shallow(<ProviderLink cardId={cardId} />);
});
describe('behavior', () => {
it('initializes credit hook with cardId', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes credit.providerStatusUrl to the hyperlink href', () => {
expect(el.find('Hyperlink').props().href).toEqual(credit.providerStatusUrl);
});
it('passes providerName for the link message', () => {
expect(el.find('Hyperlink').text()).toEqual(credit.providerName);
});
});
});

View File

@@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditContent component render with action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<ActionRow
className="mt-4"
>
<Button
as="a"
className="border-gray-400"
href="test-action-href"
onClick={[MockFunction test-action-onClick]}
rel="noopener"
target="_blank"
variant="outline-primary"
>
test-action-message
</Button>
</ActionRow>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;
exports[`CreditContent component render without action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProviderLink component render snapshot 1`] = `
<Hyperlink
href="test-credit-provider-status-url"
rel="noopener"
target="_blank"
>
test-credit-provider-name
</Hyperlink>
`;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { StrictDict } from 'utils';
import { AppContext } from '@edx/frontend-platform/react';
import { hooks as appHooks } from 'data/redux';
import api from 'data/services/lms/api';
import * as module from './hooks';
export const state = StrictDict({
creditRequestData: (val) => React.useState(val), // eslint-disable-line
});
export const useCreditRequestData = (cardId) => {
const [requestData, setRequestData] = module.state.creditRequestData(null);
const { courseId } = appHooks.useCardCourseRunData(cardId);
const { providerId } = appHooks.useCardCreditData(cardId);
const { authenticatedUser } = React.useContext(AppContext);
const { username } = authenticatedUser;
const createCreditRequest = (e) => {
e.preventDefault();
api.createCreditRequest({ providerId, courseId, username })
.then(setRequestData);
};
return { requestData, createCreditRequest };
};
export default {
useCreditRequestData,
};

View File

@@ -0,0 +1,76 @@
import { AppContext } from '@edx/frontend-platform/react';
import { MockUseState } from 'testUtils';
import { hooks as appHooks } from 'data/redux';
import api from 'data/services/lms/api';
import * as hooks from './hooks';
jest.mock('@edx/frontend-platform/react', () => ({
AppContext: {
authenticatedUser: { username: 'test-username' },
},
}));
jest.mock('data/redux', () => ({
hooks: {
useCardCourseRunData: jest.fn(),
useCardCreditData: jest.fn(),
},
}));
jest.mock('data/services/lms/api', () => ({
createCreditRequest: jest.fn(),
}));
const state = new MockUseState(hooks);
const cardId = 'test-card-id';
const testValue = 'test-value';
const courseId = 'test-course-id';
const providerId = 'test-credit-provider-id';
appHooks.useCardCourseRunData.mockReturnValue({ courseId });
appHooks.useCardCreditData.mockReturnValue({ providerId });
api.createCreditRequest.mockReturnValue(Promise.resolve(testValue));
const { username } = AppContext.authenticatedUser;
let out;
describe('Credit Banner view hooks', () => {
describe('state', () => {
state.testGetter(state.keys.creditRequestData);
});
describe('useCreditRequestData', () => {
beforeEach(() => {
state.mock();
state.mockVal(state.keys.creditRequestData, testValue);
out = hooks.useCreditRequestData(cardId);
});
describe('behavior', () => {
it('initializes creditRequestData state field with null value', () => {
state.expectInitializedWith(state.keys.creditRequestData, null);
});
it('calls useCardCourseRunData with passed cardID', () => {
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
it('calls useCardCreditData with passed cardID', () => {
expect(appHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('output', () => {
it('returns requestData state value', () => {
expect(out.requestData).toEqual(testValue);
});
describe('createCreditRequest', () => {
const preventDefault = jest.fn();
const event = { preventDefault };
it('returns an event handler that prevents default click behavior', () => {
out.createCreditRequest(event);
expect(preventDefault).toHaveBeenCalled();
});
it('calls api.createCreditRequest and sets requestData with the response', async () => {
await out.createCreditRequest(event);
expect(api.createCreditRequest).toHaveBeenCalledWith({ providerId, courseId, username });
expect(state.setState.creditRequestData).toHaveBeenCalledWith(testValue);
});
});
});
});
});

View File

@@ -0,0 +1,61 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
approved: {
id: 'learner-dash.courseCard.banners.credit.approved',
description: '',
defaultMessage: '{congratulations} {providerName} has approved your request for course credit. To see your course credit, visit the {linkToProviderSite} website.',
},
congratulations: {
id: 'learner-dash.courseCard.banners.credit.congratulations',
description: '',
defaultMessage: 'Congratulations!',
},
eligible: {
id: 'learner-dash.courseCard.banners.credit.eligible',
description: '',
defaultMessage: 'You have completed this course and are eligible to purchase course credit. Select {getCredit} to get started.',
},
eligibleFromProvider: {
id: 'learner-dash.courseCard.banners.credit.eligibleFromProvider',
description: '',
defaultMessage: 'You are now eligible for credit from {providerName}. Congratulations!',
},
getCredit: {
id: 'learner-dash.courseCard.banners.credit.getCredit',
description: '',
defaultMessage: 'Get Credit',
},
mustRequest: {
id: 'learner-dash.courseCard.banners.credit.mustRequest',
description: '',
defaultMessage: 'Thank you for your payment. To receive course credit, you must request credit at the {linkToProviderSite} website. Select {requestCredit} to get started',
},
received: {
id: 'learner-dash.courseCard.banners.credit.received',
description: '',
defaultMessage: '{providerName} has received your course credit request. We will update you when credit processing is complete.',
},
rejected: {
id: 'learner-dash.courseCard.banners.credit.rejected',
description: '',
defaultMessage: '{providerName} did not approve your request for course credit. For more information, contact {linkToProviderSite} directly.',
},
requestCredit: {
id: 'learner-dash.courseCard.banners.credit.requestCredit',
description: '',
defaultMessage: 'Request Credit',
},
viewCredit: {
id: 'learner-dash.courseCard.banners.credit.viewCredit',
description: '',
defaultMessage: 'View Credit',
},
viewDetails: {
id: 'learner-dash.courseCard.banners.credit.viewDetails',
description: '',
defaultMessage: 'View Details',
},
});
export default messages;

View File

@@ -5,6 +5,7 @@ import { hooks as appHooks } from 'data/redux';
import CourseBanner from './CourseBanner';
import CertificateBanner from './CertificateBanner';
import CreditBanner from './CreditBanner';
import EntitlementBanner from './EntitlementBanner';
export const CourseCardBanners = ({ cardId }) => {
@@ -14,6 +15,7 @@ export const CourseCardBanners = ({ cardId }) => {
<CourseBanner cardId={cardId} />
<EntitlementBanner cardId={cardId} />
{isEnrolled && <CertificateBanner cardId={cardId} />}
{isEnrolled && <CreditBanner cardId={cardId} />}
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { StrictDict } from 'utils';
export const requestStatuses = StrictDict({
pending: 'pending',
approved: 'approved',
rejected: 'rejected',
});
export default { requestStatuses };

View File

@@ -61,6 +61,23 @@ export const courseCard = StrictDict({
unenrollUrl: baseAppUrl(courseRun.unenrollUrl),
}),
),
credit: mkCardSelector(
cardSimpleSelectors.credit,
(credit) => {
if (!credit || Object.keys(credit).length === 0) {
return { isEligible: false };
}
return {
isEligible: true,
providerStatusUrl: credit.providerStatusUrl,
providerName: credit.providerName,
providerId: credit.providerId,
error: credit.error,
purchased: credit.purchased,
requestStatus: credit.requestStatus,
};
},
),
enrollment: mkCardSelector(
cardSimpleSelectors.enrollment,
(enrollment) => {

View File

@@ -185,6 +185,42 @@ describe('courseCard selectors module', () => {
expect(selected.unenrollUrl).toEqual(baseAppUrl(testData.unenrollUrl));
});
});
describe('credit selector', () => {
const credit = {
providerStatusUrl: 'test-provider-status-url',
providerName: 'test-provider-name',
providerId: 'test-provider-id',
error: 'test-provider-id',
purchased: 'test-purchased',
requestStatus: 'test-request-status',
};
it('returns a card selector based on credit cardSimpleSelector', () => {
loadSelector(courseCard.credit, {});
expect(simpleSelector).toEqual(cardSimpleSelectors.credit);
});
it('returns { isEligible: false } if empty object received for credit', () => {
loadSelector(courseCard.credit, {});
expect(selected).toEqual({ isEligible: false });
});
describe('credit fields when credit object is passed', () => {
beforeEach(() => {
loadSelector(courseCard.credit, credit);
});
it('returns isEligible: true', () => {
expect(selected.isEligible).toEqual(true);
});
it('returns provider status url, name, and id', () => {
expect(selected.providerStatusUrl).toEqual(credit.providerStatusUrl);
expect(selected.providerName).toEqual(credit.providerName);
expect(selected.providerId).toEqual(credit.providerId);
});
it('returns error, purchased and requestStatus fields', () => {
expect(selected.error).toEqual(credit.error);
expect(selected.purchased).toEqual(credit.purchased);
expect(selected.requestStatus).toEqual(credit.requestStatus);
});
});
});
describe('enrollment selector', () => {
beforeEach(() => {
loadSelector(courseCard.enrollment, {

View File

@@ -23,6 +23,7 @@ export const cardSimpleSelectors = StrictDict({
course: ({ course }) => course,
courseProvider: ({ courseProvider }) => courseProvider,
courseRun: ({ courseRun }) => courseRun,
credit: ({ credit }) => credit,
enrollment: ({ enrollment }) => enrollment,
entitlement: ({ entitlement }) => entitlement,
gradeData: ({ gradeData }) => gradeData,

View File

@@ -42,6 +42,7 @@ describe('app simple selectors', () => {
keys.course,
keys.courseProvider,
keys.courseRun,
keys.credit,
keys.enrollment,
keys.entitlement,
keys.gradeData,

View File

@@ -29,6 +29,7 @@ export const useCourseCardData = (selector) => (cardId) => useSelector(
export const useCardCertificateData = useCourseCardData(courseCard.certificate);
export const useCardCourseData = useCourseCardData(courseCard.course);
export const useCardCourseRunData = useCourseCardData(courseCard.courseRun);
export const useCardCreditData = useCourseCardData(courseCard.credit);
export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment);
export const useCardEntitlementData = useCourseCardData(courseCard.entitlement);
export const useCardGradeData = useCourseCardData(courseCard.gradeData);

View File

@@ -25,9 +25,13 @@ export const updateEntitlementEnrollment = ({ uuid, courseId }) => post(
{ [apiKeys.courseRunId]: courseId },
);
export const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client().delete(
stringifyUrl(urls.entitlementEnrollment(uuid), { [apiKeys.isRefund]: isRefundable }),
);
export const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client()
.delete(
stringifyUrl(
urls.entitlementEnrollment(uuid),
{ [apiKeys.isRefund]: isRefundable },
),
);
export const updateEmailSettings = ({ courseId, enable }) => post(
urls.updateEmailSettings,
@@ -62,6 +66,12 @@ export const logShare = ({ courseId, site }) => module.logEvent({
},
});
export const formDataHeaders = { 'Content-Type': 'multipart/form-data' };
export const createCreditRequest = ({ providerId, courseId, username }) => post(
urls.creditRequestUrl(providerId),
{ course_key: courseId, username },
);
export default {
initializeList,
unenrollFromCourse,

View File

@@ -16,7 +16,7 @@ jest.mock('./utils', () => {
client: () => ({ delete: deleteFn }),
delete: deleteFn,
get: (...args) => ({ get: args }),
post: (...args) => ({ post: args }),
post: jest.fn((...args) => ({ post: args })),
stringifyUrl: (...args) => ({ stringifyUrl: args }),
};
});
@@ -29,6 +29,9 @@ const isRefundable = 'test-is-refundable';
const moduleKeys = keyStore(api);
describe('lms api methods', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('initializeList', () => {
test('calls get with the correct url and user', () => {
const userArg = {
@@ -135,4 +138,17 @@ describe('lms api methods', () => {
});
});
});
describe('credit requests', () => {
describe('createCreditRequest', () => {
const providerId = 'test-provider-id';
const username = 'test-username';
it('posts course ID and username to credit request url', () => {
api.createCreditRequest({ providerId, courseId, username });
expect(utils.post).toHaveBeenCalledWith(
urls.creditRequestUrl(providerId),
{ course_key: courseId, username },
);
});
});
});
});

View File

@@ -1,4 +1,5 @@
import { StrictDict } from 'utils';
import creditVals from 'data/constants/credit';
export const providers = StrictDict({
edx: { name: 'edX Course Provider' },
@@ -123,6 +124,15 @@ export const genCourseRunData = (data = {}) => ({
...data,
});
export const creditData = {
providerStatusUrl: 'test-provider-status-url',
providerName: 'Credit Provider Name',
providerId: 'credit-provider-id',
error: false,
purchased: false,
requestStatus: null,
};
export const genEnrollmentData = (data = {}) => ({
coursewareAccess: {
isTooEarly: false,
@@ -178,7 +188,7 @@ export const availableSessions = [
},
];
export const courseRuns = [
const auditCourses = [
// audit, course run not started
{
courseName: 'Audit Course, Course run not started',
@@ -324,6 +334,8 @@ export const courseRuns = [
},
grade: { isPassing: false },
},
];
const verifiedCourses = [
// verified, course not started, learner not started
{
courseName: 'Verified Course, Course and learner not started',
@@ -436,6 +448,8 @@ export const courseRuns = [
certPreviewUrl: bannerImgSrc,
},
},
];
const fulfilledEntitlementCourses = [
// Entitlement - not started
{
courseName: 'Entitlement Course, not started',
@@ -605,6 +619,62 @@ export const courseRuns = [
},
},
];
const creditCourses = [
{
courseName: 'Credit - Eligible for credit from unknown provider',
credit: {
...creditData,
providerName: null,
providerId: null,
},
enrollment: { isEnrolled: true },
},
{
courseName: 'Credit - Eligible for credit from known provider',
credit: creditData,
enrollment: { isEnrolled: true },
},
{
courseName: 'Credit - Purchased but must request',
credit: { ...creditData, purchased: true },
enrollment: { isEnrolled: true },
},
{
courseName: 'Credit - Credit Request Pending',
credit: {
...creditData,
purchased: true,
requestStatus: creditVals.requestStatuses.pending,
},
enrollment: { isEnrolled: true },
},
{
courseName: 'Credit - Credit Request Approved',
credit: {
...creditData,
purchased: true,
requestStatus: creditVals.requestStatuses.approved,
},
enrollment: { isEnrolled: true },
},
{
courseName: 'Credit - Credit Request Rejected, Error thrown',
credit: {
...creditData,
purchased: true,
requestStatus: creditVals.requestStatuses.rejected,
error: true,
},
enrollment: { isEnrolled: true },
},
];
export const courseRuns = [
...auditCourses,
...verifiedCourses,
...fulfilledEntitlementCourses,
...creditCourses,
];
// unfulfilled entitlement select session
// unfulfilled entitlement select session with deadline
@@ -688,6 +758,7 @@ export const compileCourseRunData = ({ courseName, ...data }, index) => {
const out = {
gradeData: { isPassing: true },
entitlement: null,
credit: {},
...data,
certificate: genCertificateData(data.certificate),
enrollment: genEnrollmentData({ lastEnrolled, ...data.enrollment }),

View File

@@ -2,8 +2,9 @@ import { StrictDict } from 'utils';
import { configuration } from 'config';
const baseUrl = `${configuration.LMS_BASE_URL}`;
export const ecommerceUrl = `${configuration.ECOMMERCE_PUBLIC_URL_ROOT}`;
const api = `${baseUrl}/api`;
export const api = `${baseUrl}/api`;
// const init = `${api}learner_home/mock/init`; // mock endpoint for testing
const init = `${api}/learner_home/init`;
@@ -22,10 +23,15 @@ export const learningMfeUrl = (url) => updateUrl(configuration.LEARNING_BASE_URL
// static view url
const programsUrl = baseAppUrl('/dashboard/programs');
export const creditPurchaseUrl = (courseId) => `${ecommerceUrl}/credit/checkout/${courseId}`;
export const creditRequestUrl = (providerId) => `${api}/credit/v1/providers/${providerId}/request`;
export default StrictDict({
api,
baseAppUrl,
courseUnenroll,
creditPurchaseUrl,
creditRequestUrl,
entitlementEnrollment,
event,
init,

View File

@@ -32,4 +32,20 @@ describe('urls', () => {
expect(urls.learningMfeUrl(null)).toEqual(null);
});
});
describe('creditPurchaseUrl', () => {
it('builds from ecommerce url and loads courseId', () => {
const courseId = 'test-course-id';
const url = urls.creditPurchaseUrl(courseId);
expect(url.startsWith(urls.ecommerceUrl)).toEqual(true);
expect(url).toEqual(expect.stringContaining(courseId));
});
});
describe('creditRequestUrl', () => {
it('builds from api url and loads providerId', () => {
const providerId = 'test-provider-id';
const url = urls.creditRequestUrl(providerId);
expect(url.startsWith(urls.api)).toEqual(true);
expect(url).toEqual(expect.stringContaining(providerId));
});
});
});

View File

@@ -5,6 +5,7 @@ export const categories = StrictDict({
upgrade: 'upgrade',
userEngagement: 'user-engagement',
searchButton: 'search_button',
credit: 'credit',
});
export const events = StrictDict({
@@ -48,6 +49,7 @@ export const eventNames = StrictDict({
enterpriseDashboardModalCTAClicked: `${learnerPortal}.dashboard_cta.clicked`,
enterpriseDashboardModalClosed: `${learnerPortal}.closed`,
findCoursesClicked: 'edx.bi.dashboard.find_courses_button.clicked',
purchaseCredit: 'edx.bi.credit.clicked_purchase_credit',
});
export const linkNames = StrictDict({

View File

@@ -1,4 +1,5 @@
import course from './trackers/course';
import credit from './trackers/credit';
import engagement from './trackers/engagement';
import enterpriseDashboard from './trackers/enterpriseDashboard';
import entitlements from './trackers/entitlements';
@@ -7,6 +8,7 @@ import findCourses from './trackers/findCourses';
export default {
course,
credit,
engagement,
enterpriseDashboard,
entitlements,

View File

@@ -0,0 +1,19 @@
import { createEventTracker, createLinkTracker } from 'data/services/segment/utils';
import { categories, eventNames } from '../constants';
/**
* Create event tracker for purchase credit event
* @param {string} fromCourseRun - course run identifier for leaving course
* @return {callback} - callback that triggers the event tracker
*/
export const purchase = (courseKey, href) => createLinkTracker(
createEventTracker(eventNames.purchaseCredit, {
label: courseKey,
category: categories.credit,
}),
href,
);
export default {
purchase,
};

View File

@@ -4,6 +4,7 @@ import { StrictDict } from 'utils';
import { RequestStates } from 'data/constants/requests';
import * as module from './hooks';
import testData from './testData';
import api from './api';
export const searchCourseEventName = 'learner_home.widget.search_course';
@@ -11,6 +12,7 @@ export const searchCourseEventName = 'learner_home.widget.search_course';
export const state = StrictDict({
requestState: (val) => React.useState(val), // eslint-disable-line
data: (val) => React.useState(val), // eslint-disable-line
courses: (val) => React.useState(val), // eslint-disable-line
});
export const useFetchCourses = (setRequestState, setData) => {
@@ -35,8 +37,16 @@ export const useRecommendationPanelData = () => {
const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
const [data, setData] = module.state.data({});
module.useFetchCourses(setRequestState, setData);
const courses = data.data?.courses || [];
const [courses, setCourses] = module.state.courses(data.data?.courses || []);
const isPersonalizedRecommendation = data.data?.isPersonalizedRecommendation || false;
React.useEffect(() => {
window.loadMockRecommendations = () => {
setCourses(testData.courses);
setRequestState(RequestStates.completed);
}
}, []);
return {
courses,
isLoaded: requestState === RequestStates.completed && courses.length > 0,

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line
export default {"courses":[{"courseKey":"ETSx+TOEFLx","logoImageUrl":"https://prod-discovery.edx-cdn.org/organization/logos/9d9e1a30-c34d-4ad1-8c5a-d2410db8c123-8beea336c2a4.png","marketingUrl":"https://www.edx.org/course/toefl-test-preparation-the-insiders-guide?utm_source=lms_catalog_service_user&utm_medium=affiliate_partner","title":"TOEFL® Test Preparation: The Insiders Guide"},{"courseKey":"HarvardX+CS50P","logoImageUrl":"https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png","marketingUrl":"https://www.edx.org/course/cs50s-introduction-to-programming-with-python?utm_source=lms_catalog_service_user&utm_medium=affiliate_partner","title":"CS50's Introduction to Programming with Python"},{"courseKey":"UQx+IELTSx","logoImageUrl":"https://prod-discovery.edx-cdn.org/organization/logos/8554749f-b920-4d7f-8986-af6bb95290aa-f336c6a2ca11.png","marketingUrl":"https://www.edx.org/course/ielts-academic-test-preparation?utm_source=lms_catalog_service_user&utm_medium=affiliate_partner","title":"IELTS Academic Test Preparation"},{"courseKey":"QUx+QU01X02","logoImageUrl":"https://prod-discovery.edx-cdn.org/organization/logos/7b6ca3d5-1030-408f-b1a4-fb140db1854e-7337e1e6deaf.png","marketingUrl":"https://www.edx.org/course/arabic-for-non-arabic-speakers?utm_source=lms_catalog_service_user&utm_medium=affiliate_partner","title":"Arabic for non-Arabic Speakers"},{"courseKey":"HarvardX+CS50W","logoImageUrl":"https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png","marketingUrl":"https://www.edx.org/course/cs50s-web-programming-with-python-and-javascript?utm_source=lms_catalog_service_user&utm_medium=affiliate_partner","title":"CS50's Web Programming with Python and JavaScript"}],"isPersonalizedRecommendation":true};