From 166c64a391ebad96497340a88a86ae658fc83e18 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Mon, 19 Dec 2022 13:25:02 -0500 Subject: [PATCH] feat: credit banner (#96) --- package-lock.json | 150 +++--------------- package.json | 4 +- src/App.scss | 2 +- src/components/EmailLink.jsx | 17 ++ .../__snapshots__/index.test.jsx.snap | 39 +++++ .../CourseCardBanners/CreditBanner/hooks.js | 40 +++++ .../CreditBanner/hooks.test.js | 90 +++++++++++ .../CourseCardBanners/CreditBanner/index.jsx | 35 ++++ .../CreditBanner/index.test.jsx | 82 ++++++++++ .../CreditBanner/messages.js | 11 ++ .../CreditBanner/views/ApprovedContent.jsx | 33 ++++ .../views/ApprovedContent.test.jsx | 59 +++++++ .../CreditBanner/views/EligibleContent.jsx | 34 ++++ .../views/EligibleContent.test.jsx | 85 ++++++++++ .../CreditBanner/views/MustRequestContent.jsx | 33 ++++ .../views/MustRequestContent.test.jsx | 61 +++++++ .../CreditBanner/views/PendingContent.jsx | 32 ++++ .../views/PendingContent.test.jsx | 62 ++++++++ .../CreditBanner/views/RejectedContent.jsx | 27 ++++ .../views/RejectedContent.test.jsx | 54 +++++++ .../views/components/CreditContent.jsx | 47 ++++++ .../views/components/CreditContent.test.jsx | 54 +++++++ .../__snapshots__/index.test.jsx.snap | 32 ++++ .../components/CreditRequestForm/hooks.js | 13 ++ .../CreditRequestForm/hooks.test.js | 45 ++++++ .../components/CreditRequestForm/index.jsx | 43 +++++ .../CreditRequestForm/index.test.jsx | 65 ++++++++ .../components/CreditRequestForm/ref.test.jsx | 34 ++++ .../views/components/ProviderLink.jsx | 24 +++ .../views/components/ProviderLink.test.jsx | 42 +++++ .../__snapshots__/CreditContent.test.jsx.snap | 56 +++++++ .../__snapshots__/ProviderLink.test.jsx.snap | 11 ++ .../CreditBanner/views/hooks.js | 31 ++++ .../CreditBanner/views/hooks.test.js | 76 +++++++++ .../CreditBanner/views/messages.js | 61 +++++++ .../components/CourseCardBanners/index.jsx | 2 + src/data/constants/credit.js | 9 ++ src/data/redux/app/selectors/courseCard.js | 17 ++ .../redux/app/selectors/courseCard.test.js | 36 +++++ .../redux/app/selectors/simpleSelectors.js | 1 + .../app/selectors/simpleSelectors.test.js | 1 + src/data/redux/hooks.js | 1 + src/data/services/lms/api.js | 16 +- src/data/services/lms/api.test.js | 18 ++- src/data/services/lms/fakeData/courses.js | 73 ++++++++- src/data/services/lms/urls.js | 8 +- src/data/services/lms/urls.test.js | 16 ++ src/tracking/constants.js | 2 + src/tracking/index.js | 2 + src/tracking/trackers/credit.js | 19 +++ src/widgets/RecommendationsPanel/hooks.js | 12 +- src/widgets/RecommendationsPanel/testData.js | 2 + 52 files changed, 1678 insertions(+), 141 deletions(-) create mode 100644 src/components/EmailLink.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/__snapshots__/index.test.jsx.snap create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/messages.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/__snapshots__/index.test.jsx.snap create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/hooks.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/hooks.test.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/index.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/index.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/ref.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/__snapshots__/CreditContent.test.jsx.snap create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/__snapshots__/ProviderLink.test.jsx.snap create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/messages.js create mode 100644 src/data/constants/credit.js create mode 100644 src/tracking/trackers/credit.js create mode 100644 src/widgets/RecommendationsPanel/testData.js diff --git a/package-lock.json b/package-lock.json index 9f30bbe..9aa0dbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5b74071..f3fc150 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.scss b/src/App.scss index dc95695..14948b8 100755 --- a/src/App.scss +++ b/src/App.scss @@ -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; } diff --git a/src/components/EmailLink.jsx b/src/components/EmailLink.jsx new file mode 100644 index 0000000..d57ec67 --- /dev/null +++ b/src/components/EmailLink.jsx @@ -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 ( + {address} + ); +}; +EmailLink.defaultProps = { address: null }; +EmailLink.propTypes = { address: PropTypes.string }; + +export default EmailLink; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/__snapshots__/index.test.jsx.snap b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..4096091 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/__snapshots__/index.test.jsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreditBanner component render with error state snapshot 1`] = ` + +

+ , + } + } + /> +

+ +
+`; + +exports[`CreditBanner component render with no error state snapshot 1`] = ` + + + +`; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js new file mode 100644 index 0000000..918cf81 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js @@ -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, +}; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js new file mode 100644 index 0000000..df91c7a --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js @@ -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); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.jsx new file mode 100644 index 0000000..de58d41 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.jsx @@ -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 = (); + return ( + + {error && ( +

+ {formatMessage(messages.error, { supportEmailLink })} +

+ )} + +
+ ); +}; +CreditBanner.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default CreditBanner; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.test.jsx new file mode 100644 index 0000000..f8fea3c --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/index.test.jsx @@ -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(); + }); + 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(); + }); + 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: (), + }), + )).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(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + it('loads ContentComponent with cardId', () => { + expect(el.find('ContentComponent').props().cardId).toEqual(cardId); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/messages.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/messages.js new file mode 100644 index 0000000..bf0adaa --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/messages.js @@ -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; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx new file mode 100644 index 0000000..ba3d84f --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx @@ -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 ( + {formatMessage(messages.congratulations)}, + linkToProviderSite: , + providerName, + }, + )} + /> + ); +}; +ApprovedContent.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default ApprovedContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.test.jsx new file mode 100644 index 0000000..fd13b6f --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.test.jsx @@ -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(); + }); + 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: ({formatMessage(messages.congratulations)}), + linkToProviderSite: , + providerName: credit.providerName, + }, + )); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx new file mode 100644 index 0000000..7c0a700 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx @@ -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: ({getCredit}) }); + + return ( + + ); +}; +EligibleContent.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default EligibleContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx new file mode 100644 index 0000000..328356a --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx @@ -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(); +}; +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: ({formatMessage(messages.getCredit)}) }, + )); + }); + test('message is formatted eligible message if provider', () => { + expect(component.props().message).toEqual( + formatMessage(messages.eligibleFromProvider, { providerName: credit.providerName }), + ); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx new file mode 100644 index 0000000..8f3d641 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx @@ -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 ( + ), + requestCredit: ({formatMessage(messages.requestCredit)}), + })} + requestData={requestData} + /> + ); +}; +MustRequestContent.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default MustRequestContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.test.jsx new file mode 100644 index 0000000..663e69f --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.test.jsx @@ -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(); +}; +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: (), + requestCredit: ({formatMessage(messages.requestCredit)}), + }), + ); + }); + test('requestData drawn from useCreditRequestData hook', () => { + expect(component.props().requestData).toEqual(requestData); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx new file mode 100644 index 0000000..913cf30 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx @@ -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 ( + <> + + + ); +}; +PendingContent.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default PendingContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.test.jsx new file mode 100644 index 0000000..8c93101 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.test.jsx @@ -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(); +}; +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); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx new file mode 100644 index 0000000..c02eadc --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx @@ -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 ( + ), + })} + /> + ); +}; +RejectedContent.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +export default RejectedContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx new file mode 100644 index 0000000..cdd8c01 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx @@ -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(); }; +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: , + providerName: credit.providerName, + }, + )); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.jsx new file mode 100644 index 0000000..802abe5 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.jsx @@ -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 }) => ( + <> +
+ {message} +
+ {action && ( + + + + )} + + +); +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; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.test.jsx new file mode 100644 index 0000000..19c2504 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditContent.test.jsx @@ -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(); + }); + 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(); + }); + 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); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/__snapshots__/index.test.jsx.snap b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..b374f55 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/CreditRequestForm/__snapshots__/index.test.jsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreditRequestForm component render output valid requestData snapshot 1`] = ` +
+ + + + + + + +`; + +exports[`CreditContent component render without action snapshot 1`] = ` + +
+ test-message +
+ +
+`; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/__snapshots__/ProviderLink.test.jsx.snap b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/__snapshots__/ProviderLink.test.jsx.snap new file mode 100644 index 0000000..8542496 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/__snapshots__/ProviderLink.test.jsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProviderLink component render snapshot 1`] = ` + + test-credit-provider-name + +`; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js new file mode 100644 index 0000000..6c18e93 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js @@ -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, +}; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js new file mode 100644 index 0000000..8b6ad40 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js @@ -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); + }); + }); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/messages.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/messages.js new file mode 100644 index 0000000..8f92a4e --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/messages.js @@ -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; diff --git a/src/containers/CourseCard/components/CourseCardBanners/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/index.jsx index 700ae20..3b693ce 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.jsx @@ -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 }) => { {isEnrolled && } + {isEnrolled && } ); }; diff --git a/src/data/constants/credit.js b/src/data/constants/credit.js new file mode 100644 index 0000000..a05a722 --- /dev/null +++ b/src/data/constants/credit.js @@ -0,0 +1,9 @@ +import { StrictDict } from 'utils'; + +export const requestStatuses = StrictDict({ + pending: 'pending', + approved: 'approved', + rejected: 'rejected', +}); + +export default { requestStatuses }; diff --git a/src/data/redux/app/selectors/courseCard.js b/src/data/redux/app/selectors/courseCard.js index dc13015..01faaca 100644 --- a/src/data/redux/app/selectors/courseCard.js +++ b/src/data/redux/app/selectors/courseCard.js @@ -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) => { diff --git a/src/data/redux/app/selectors/courseCard.test.js b/src/data/redux/app/selectors/courseCard.test.js index 38aa664..9331f85 100644 --- a/src/data/redux/app/selectors/courseCard.test.js +++ b/src/data/redux/app/selectors/courseCard.test.js @@ -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, { diff --git a/src/data/redux/app/selectors/simpleSelectors.js b/src/data/redux/app/selectors/simpleSelectors.js index a231911..871c0fe 100644 --- a/src/data/redux/app/selectors/simpleSelectors.js +++ b/src/data/redux/app/selectors/simpleSelectors.js @@ -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, diff --git a/src/data/redux/app/selectors/simpleSelectors.test.js b/src/data/redux/app/selectors/simpleSelectors.test.js index 6f5984a..90b0f9c 100644 --- a/src/data/redux/app/selectors/simpleSelectors.test.js +++ b/src/data/redux/app/selectors/simpleSelectors.test.js @@ -42,6 +42,7 @@ describe('app simple selectors', () => { keys.course, keys.courseProvider, keys.courseRun, + keys.credit, keys.enrollment, keys.entitlement, keys.gradeData, diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js index 58d38da..bbad44d 100644 --- a/src/data/redux/hooks.js +++ b/src/data/redux/hooks.js @@ -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); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index 5f3b37c..c8109fb 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -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, diff --git a/src/data/services/lms/api.test.js b/src/data/services/lms/api.test.js index 31de3db..a59bf4c 100644 --- a/src/data/services/lms/api.test.js +++ b/src/data/services/lms/api.test.js @@ -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 }, + ); + }); + }); + }); }); diff --git a/src/data/services/lms/fakeData/courses.js b/src/data/services/lms/fakeData/courses.js index 44f94e4..fe1923e 100644 --- a/src/data/services/lms/fakeData/courses.js +++ b/src/data/services/lms/fakeData/courses.js @@ -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 }), diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index d95c369..9832519 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -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, diff --git a/src/data/services/lms/urls.test.js b/src/data/services/lms/urls.test.js index 2d7ad94..05d4f86 100644 --- a/src/data/services/lms/urls.test.js +++ b/src/data/services/lms/urls.test.js @@ -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)); + }); + }); }); diff --git a/src/tracking/constants.js b/src/tracking/constants.js index 32bbc5c..19309dc 100644 --- a/src/tracking/constants.js +++ b/src/tracking/constants.js @@ -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({ diff --git a/src/tracking/index.js b/src/tracking/index.js index 30f5557..ef509c5 100644 --- a/src/tracking/index.js +++ b/src/tracking/index.js @@ -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, diff --git a/src/tracking/trackers/credit.js b/src/tracking/trackers/credit.js new file mode 100644 index 0000000..12982c7 --- /dev/null +++ b/src/tracking/trackers/credit.js @@ -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, +}; diff --git a/src/widgets/RecommendationsPanel/hooks.js b/src/widgets/RecommendationsPanel/hooks.js index 9918cb2..953a7d7 100644 --- a/src/widgets/RecommendationsPanel/hooks.js +++ b/src/widgets/RecommendationsPanel/hooks.js @@ -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, diff --git a/src/widgets/RecommendationsPanel/testData.js b/src/widgets/RecommendationsPanel/testData.js new file mode 100644 index 0000000..84e11b5 --- /dev/null +++ b/src/widgets/RecommendationsPanel/testData.js @@ -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 Insider’s 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};