diff --git a/package-lock.json b/package-lock.json index dc107cb24..27ff6799e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/paragon": "^23.4.5", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", + "@tanstack/react-query": "^5.90.16", "classnames": "^2.3.1", "core-js": "3.47.0", "font-awesome": "4.7.0", @@ -34,14 +33,9 @@ "react-dom": "^18.3.1", "react-helmet": "^6.1.0", "react-intl": "6.8.9", - "react-redux": "^7.2.4", "react-router-dom": "6.30.3", "react-share": "^4.4.0", - "redux": "4.2.1", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", "regenerator-runtime": "^0.14.0", - "reselect": "^4.0.0", "universal-cookie": "^4.0.4", "util": "^0.12.4" }, @@ -59,8 +53,7 @@ "jest-expect-message": "^1.1.3", "jest-when": "^3.6.0", "react-dev-utils": "^12.0.0", - "react-test-renderer": "^18.3.1", - "redux-mock-store": "^1.5.4" + "react-test-renderer": "^18.3.1" } }, "node_modules/@adobe/css-tools": { @@ -2590,14 +2583,14 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, @@ -2613,9 +2606,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -4365,16 +4358,16 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@newrelic/publish-sourcemap": { @@ -5318,67 +5311,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@redux-devtools/extension": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz", - "integrity": "sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2", - "immutable": "^4.3.4" - }, - "peerDependencies": { - "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit/node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -5440,18 +5372,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -5724,6 +5644,32 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -5860,9 +5806,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -9595,12 +9541,6 @@ } } }, - "node_modules/deep-diff": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", - "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -13210,22 +13150,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.0.tgz", - "integrity": "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -15660,13 +15584,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -19104,37 +19021,6 @@ "@babel/runtime": "^7.9.2" } }, - "node_modules/redux-logger": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", - "integrity": "sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg==", - "license": "MIT", - "dependencies": { - "deep-diff": "^0.3.5" - } - }, - "node_modules/redux-mock-store": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.5.tgz", - "integrity": "sha512-YxX+ofKUTQkZE4HbhYG4kKGr7oCTJfB0GLy7bSeqx86GLpGirrbUWstMnqXkqHNaQpcnbMGbof2dYs5KsPE6Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.isplainobject": "^4.0.6" - }, - "peerDependencies": { - "redux": "*" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "license": "MIT", - "peerDependencies": { - "redux": "^4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -19312,12 +19198,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index 321c05b76..6fd187c97 100755 --- a/package.json +++ b/package.json @@ -41,8 +41,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/paragon": "^23.4.5", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", + "@tanstack/react-query": "^5.90.16", "classnames": "^2.3.1", "core-js": "3.47.0", "font-awesome": "4.7.0", @@ -54,14 +53,9 @@ "react-dom": "^18.3.1", "react-helmet": "^6.1.0", "react-intl": "6.8.9", - "react-redux": "^7.2.4", "react-router-dom": "6.30.3", "react-share": "^4.4.0", - "redux": "4.2.1", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", "regenerator-runtime": "^0.14.0", - "reselect": "^4.0.0", "universal-cookie": "^4.0.4", "util": "^0.12.4" }, @@ -79,7 +73,6 @@ "jest-expect-message": "^1.1.3", "jest-when": "^3.6.0", "react-dev-utils": "^12.0.0", - "react-test-renderer": "^18.3.1", - "redux-mock-store": "^1.5.4" + "react-test-renderer": "^18.3.1" } } diff --git a/src/App.jsx b/src/App.jsx index 2c148f98e..c8d914f9c 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,60 +5,30 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; -import { ErrorPage, AppContext } from '@edx/frontend-platform/react'; +import { ErrorPage } from '@edx/frontend-platform/react'; import { FooterSlot } from '@edx/frontend-component-footer'; import { Alert } from '@openedx/paragon'; -import { RequestKeys } from 'data/constants/requests'; -import store from 'data/store'; -import { - selectors, - actions, -} from 'data/redux'; -import { reduxHooks } from 'hooks'; import Dashboard from 'containers/Dashboard'; -import track from 'tracking'; - -import fakeData from 'data/services/lms/fakeData/courses'; - import AppWrapper from 'containers/AppWrapper'; import LearnerDashboardHeader from 'containers/LearnerDashboardHeader'; import { getConfig } from '@edx/frontend-platform'; +import { useInitializeLearnerHome } from 'data/hooks'; +import { useMasquerade } from 'data/context'; import messages from './messages'; import './App.scss'; export const App = () => { - const { authenticatedUser } = React.useContext(AppContext); const { formatMessage } = useIntl(); - const isFailed = { - initialize: reduxHooks.useRequestIsFailed(RequestKeys.initialize), - refreshList: reduxHooks.useRequestIsFailed(RequestKeys.refreshList), - }; - const hasNetworkFailure = isFailed.initialize || isFailed.refreshList; - const { supportEmail } = reduxHooks.usePlatformSettingsData(); - const loadData = reduxHooks.useLoadData(); + const { masqueradeUser } = useMasquerade(); + const { data, isError } = useInitializeLearnerHome(); + const hasNetworkFailure = !masqueradeUser && isError; + const supportEmail = data?.platformSettings?.supportEmail || undefined; + /* istanbul ignore next */ React.useEffect(() => { - if (authenticatedUser?.administrator || getConfig().NODE_ENV === 'development') { - window.loadEmptyData = () => { - loadData({ ...fakeData.globalData, courses: [] }); - }; - window.loadMockData = () => { - loadData({ - ...fakeData.globalData, - courses: [ - ...fakeData.courseRunData, - ...fakeData.entitlementData, - ], - }); - }; - window.store = store; - window.selectors = selectors; - window.actions = actions; - window.track = track; - } if (getConfig().HOTJAR_APP_ID) { try { initializeHotjar({ @@ -70,7 +40,7 @@ export const App = () => { logError(error); } } - }, [authenticatedUser, loadData]); + }, []); return ( <> diff --git a/src/App.test.jsx b/src/App.test.jsx index 102d3792d..149419545 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -3,30 +3,24 @@ import { render, screen, waitFor } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; -import { RequestKeys } from 'data/constants/requests'; -import { reduxHooks } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import { App } from './App'; import messages from './messages'; +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + +jest.mock('data/context/MasqueradeProvider', () => ({ + useMasquerade: jest.fn(() => ({ masqueradeUser: null })), +})); + jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: jest.fn(() =>
FooterSlot
), })); jest.mock('containers/Dashboard', () => jest.fn(() =>
Dashboard
)); jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() =>
LearnerDashboardHeader
)); jest.mock('containers/AppWrapper', () => jest.fn(({ children }) =>
{children}
)); -jest.mock('data/redux', () => ({ - selectors: 'redux.selectors', - actions: 'redux.actions', - thunkActions: 'redux.thunkActions', -})); -jest.mock('hooks', () => ({ - reduxHooks: { - useRequestIsFailed: jest.fn(), - usePlatformSettingsData: jest.fn(), - useLoadData: jest.fn(), - }, -})); -jest.mock('data/store', () => 'data/store'); jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(() => ({})), @@ -37,11 +31,15 @@ jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: () => 'ErrorPage', })); -const loadData = jest.fn(); -reduxHooks.useLoadData.mockReturnValue(loadData); - const supportEmail = 'test@support.com'; -reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail }); +useInitializeLearnerHome.mockReturnValue({ + data: { + platformSettings: { + supportEmail, + }, + }, + isError: false, +}); describe('App router component', () => { describe('component', () => { @@ -66,7 +64,6 @@ describe('App router component', () => { describe('no network failure', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useRequestIsFailed.mockReturnValue(false); getConfig.mockReturnValue({}); render(); }); @@ -79,7 +76,6 @@ describe('App router component', () => { describe('no network failure with optimizely url', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useRequestIsFailed.mockReturnValue(false); getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' }); render(); }); @@ -92,7 +88,6 @@ describe('App router component', () => { describe('no network failure with optimizely project id', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useRequestIsFailed.mockReturnValue(false); getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' }); render(); }); @@ -105,7 +100,10 @@ describe('App router component', () => { describe('initialize failure', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize); + useInitializeLearnerHome.mockReturnValue({ + data: null, + isError: true, + }); getConfig.mockReturnValue({}); render(); }); @@ -119,7 +117,6 @@ describe('App router component', () => { }); describe('refresh failure', () => { beforeEach(() => { - reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList); getConfig.mockReturnValue({}); render(); }); diff --git a/src/containers/AppWrapper/indext.test.tsx b/src/containers/AppWrapper/indext.test.tsx new file mode 100644 index 000000000..00e882979 --- /dev/null +++ b/src/containers/AppWrapper/indext.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AppWrapper from './index'; + +describe('AppWrapper', () => { + it('should render children without modification', () => { + render( + +
Test Child
+
, + ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx index 559b1be23..32088179b 100644 --- a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx @@ -1,21 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const BeginCourseButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); + const { data: learnerData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const homeUrl = courseData?.courseRun?.homeUrl; + const execEdTrackingParam = useMemo(() => { + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const { authOrgId } = learnerData.enterpriseDashboard || {}; + return isExecEd2UCourse ? `?org_id=${authOrgId}` : ''; + }, [courseData.enrollment.mode, learnerData.enterpriseDashboard]); const { disableBeginCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, homeUrl + execEdTrackingParam, diff --git a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx index 8b5ea02bb..aedd64b12 100644 --- a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx @@ -1,36 +1,42 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; import track from 'tracking'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; import useActionDisabledState from '../hooks'; import BeginCourseButton from './BeginCourseButton'; +jest.mock('hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId: 'test-org-id', + }, + }, + }), +})); + jest.mock('tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardExecEdTrackingParam: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, -})); - jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); const homeUrl = 'home-url'; -reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl }); -const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`; -reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath); -reduxHooks.useTrackCourseEvent.mockImplementation( - (eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }), -); const props = { cardId: 'cardId', @@ -45,11 +51,7 @@ describe('BeginCourseButton', () => { describe('initiliaze hooks', () => { it('initializes course run data with cardId', () => { renderComponent(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - }); - it('loads exec education path param', () => { - renderComponent(); - expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); }); it('loads disabled states for begin action from action hooks', () => { renderComponent(); @@ -73,15 +75,15 @@ describe('BeginCourseButton', () => { expect(button).not.toHaveClass('disabled'); expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); - it('should track enter course clicked event on click, with exec ed param', async () => { + it('should track enter course clicked event on click, with exec ed param', () => { renderComponent(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Begin Course' }); user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, props.cardId, - homeUrl + execEdPath(props.cardId), + `${homeUrl}?org_id=test-org-id`, ); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx index 03c3a2d50..3d5c344b4 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx @@ -1,21 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseTrackingEvent, useCourseData } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const ResumeButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId); - const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); + const { data: learnerData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const resumeUrl = courseData?.courseRun?.resumeUrl; + const execEdTrackingParam = useMemo(() => { + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const { authOrgId } = learnerData.enterpriseDashboard || {}; + return isExecEd2UCourse ? `?org_id=${authOrgId}` : ''; + }, [courseData.enrollment.mode, learnerData.enterpriseDashboard]); const { disableResumeCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, resumeUrl + execEdTrackingParam, diff --git a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx index 5728b2fda..b6db87fdd 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx @@ -1,36 +1,47 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { useCourseTrackingEvent, useCourseData } from 'hooks'; -import { reduxHooks } from 'hooks'; import track from 'tracking'; import useActionDisabledState from '../hooks'; import ResumeButton from './ResumeButton'; +const authOrgId = 'auth-org-id'; +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId, + }, + }, + }), +})); + +jest.mock('hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardExecEdTrackingParam: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, -})); jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); -const resumeUrl = 'resume-url'; -reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl }); -const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`; -reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath); -reduxHooks.useTrackCourseEvent.mockImplementation( - (eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }), -); +useCourseData.mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { resumeUrl: 'home-url' }, +}); describe('ResumeButton', () => { const props = { @@ -39,10 +50,7 @@ describe('ResumeButton', () => { describe('initialize hooks', () => { beforeEach(() => render()); it('initializes course run data with cardId', () => { - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - }); - it('loads exec education path param', () => { - expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); }); it('loads disabled states for resume action from action hooks', () => { expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); @@ -73,10 +81,10 @@ describe('ResumeButton', () => { const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Resume' }); user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, props.cardId, - resumeUrl + execEdPath(props.cardId), + `home-url?org_id=${authOrgId}`, ); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx index 5762a1a6f..2e27ba153 100644 --- a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useSelectSessionModal } from 'data/context'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; @@ -11,11 +11,11 @@ import messages from './messages'; export const SelectSessionButton = ({ cardId }) => { const { formatMessage } = useIntl(); const { disableSelectSession } = useActionDisabledState(cardId); - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + const { updateSelectSessionModal } = useSelectSessionModal(); return ( updateSelectSessionModal(cardId)} > {formatMessage(messages.selectSession)} diff --git a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx index ba9e21ccf..8fc9d7dcd 100644 --- a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx @@ -1,16 +1,16 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { useSelectSessionModal } from 'data/context'; -import { reduxHooks } from 'hooks'; import useActionDisabledState from '../hooks'; import SelectSessionButton from './SelectSessionButton'; -jest.mock('hooks', () => ({ - reduxHooks: { - useUpdateSelectSessionModalCallback: jest.fn(), - }, +jest.mock('data/context', () => ({ + useSelectSessionModal: jest.fn().mockReturnValue({ + updateSelectSessionModal: jest.fn(), + }), })); jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false }))); @@ -33,11 +33,15 @@ describe('SelectSessionButton', () => { }); describe('on click', () => { it('should call openSessionModal', async () => { + const mockedUpdateSelectSessionModal = jest.fn(); + useSelectSessionModal.mockReturnValue({ + updateSelectSessionModal: mockedUpdateSelectSessionModal, + }); render(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Select Session' }); await user.click(button); - expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId); + expect(mockedUpdateSelectSessionModal).toHaveBeenCalledWith(props.cardId); }); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx index ef74b0dc4..87e7335d3 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx @@ -4,17 +4,18 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseTrackingEvent, useCourseData } from 'hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const ViewCourseButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const homeUrl = courseData?.courseRun?.homeUrl; const { disableViewCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, homeUrl, diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx index a9b2f654f..d5cbc23e3 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx @@ -1,24 +1,27 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { useCourseTrackingEvent } from 'hooks'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; import useActionDisabledState from '../hooks'; import ViewCourseButton from './ViewCourseButton'; +jest.mock('hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + courseRun: { homeUrl: 'homeUrl' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })), - useTrackCourseEvent: jest.fn(), - }, -})); jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); @@ -35,15 +38,18 @@ describe('ViewCourseButton', () => { expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); it('calls trackCourseEvent on click', async () => { + const mockedTrackCourseEvent = jest.fn(); + useCourseTrackingEvent.mockReturnValue(mockedTrackCourseEvent); render(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'View Course' }); await user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, defaultProps.cardId, homeUrl, ); + expect(mockedTrackCourseEvent).toHaveBeenCalled(); }); it('learner cannot view course', () => { useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true }); diff --git a/src/containers/CourseCard/components/CourseCardActions/index.jsx b/src/containers/CourseCard/components/CourseCardActions/index.jsx index 5f4a34fa5..4806262ac 100644 --- a/src/containers/CourseCard/components/CourseCardActions/index.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/index.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { ActionRow } from '@openedx/paragon'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useEntitlementInfo } from 'hooks'; import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot'; import SelectSessionButton from './SelectSessionButton'; @@ -12,11 +12,10 @@ import ResumeButton from './ResumeButton'; import ViewCourseButton from './ViewCourseButton'; export const CourseCardActions = ({ cardId }) => { - const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId); - const { - hasStarted, - } = reduxHooks.useCardEnrollmentData(cardId); - const { isArchived } = reduxHooks.useCardCourseRunData(cardId); + const cardData = useCourseData(cardId); + const hasStarted = cardData.enrollment.hasStarted || false; + const { isEntitlement, isFulfilled } = useEntitlementInfo(cardData); + const isArchived = cardData.courseRun.isArchived || false; return ( diff --git a/src/containers/CourseCard/components/CourseCardActions/index.test.jsx b/src/containers/CourseCard/components/CourseCardActions/index.test.jsx index 1cbacc362..4d8948be7 100644 --- a/src/containers/CourseCard/components/CourseCardActions/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/index.test.jsx @@ -1,15 +1,10 @@ import { render, screen } from '@testing-library/react'; -import { reduxHooks } from 'hooks'; - +import { useCourseData } from 'hooks'; import CourseCardActions from '.'; jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useMasqueradeData: jest.fn(), - }, + ...jest.requireActual('hooks'), + useCourseData: jest.fn(), })); jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() =>
CourseCardActionSlot
)); @@ -24,26 +19,22 @@ const props = { cardId }; describe('CourseCardActions', () => { const mockHooks = ({ isEntitlement = false, - isExecEd2UCourse = false, isFulfilled = false, isArchived = false, - isVerified = false, hasStarted = false, - isMasquerading = false, } = {}) => { - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted }); - reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); + useCourseData.mockReturnValueOnce({ + enrollment: { hasStarted }, + courseRun: { isArchived }, + entitlement: isEntitlement !== null ? { isEntitlement, isFulfilled } : null, + }); }; const renderComponent = () => render(); describe('hooks', () => { - it('initializes redux hooks', () => { + it('initializes hooks', () => { mockHooks(); renderComponent(); - expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('output', () => { @@ -63,7 +54,7 @@ describe('CourseCardActions', () => { }); describe('not entitlement, verified, or exec ed', () => { it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => { - mockHooks({ isArchived: true }); + mockHooks({ isArchived: true, isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); @@ -72,7 +63,7 @@ describe('CourseCardActions', () => { }); describe('unstarted courses', () => { it('renders CourseCardActionSlot and BeginCourseButton', () => { - mockHooks(); + mockHooks({ isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); @@ -82,7 +73,7 @@ describe('CourseCardActions', () => { }); describe('active courses (started, and not archived)', () => { it('renders CourseCardActionSlot and ResumeButton', () => { - mockHooks({ hasStarted: true }); + mockHooks({ hasStarted: true, isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx index 556628d5a..8baa07f65 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx @@ -1,12 +1,14 @@ /* eslint-disable max-len */ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { MailtoLink, Hyperlink } from '@openedx/paragon'; import { CheckCircle } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { baseAppUrl } from 'data/services/lms/urls'; -import { utilHooks, reduxHooks } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; +import { utilHooks, useCourseData } from 'hooks'; import Banner from 'components/Banner'; import messages from './messages'; @@ -14,15 +16,32 @@ import messages from './messages'; const { useFormatDate } = utilHooks; export const CertificateBanner = ({ cardId }) => { - const certificate = reduxHooks.useCardCertificateData(cardId); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); const { - isAudit, - isVerified, - } = reduxHooks.useCardEnrollmentData(cardId); - const { isPassing } = reduxHooks.useCardGradeData(cardId); - const { isArchived } = reduxHooks.useCardCourseRunData(cardId); - const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId); - const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData(); + certificate = {}, + isVerified = false, + isAudit = false, + isPassing = false, + isArchived = false, + minPassingGrade = 0, + progressUrl = '', + } = useMemo(() => ({ + isVerified: courseData?.enrollment?.isVerified, + isAudit: courseData?.enrollment?.isAudit, + certificate: courseData?.certificate || {}, + isPassing: courseData?.gradeData?.isPassing, + isArchived: courseData?.courseRun?.isArchived, + minPassingGrade: Math.floor(courseData?.courseRun?.minPassingGrade || 0 * 100), + progressUrl: baseAppUrl(courseData?.courseRun?.progressUrl || ''), + }), [courseData]); + const { supportEmail, billingEmail } = useMemo( + () => ({ + supportEmail: learnerHomeData?.platformSettings?.supportEmail, + billingEmail: learnerHomeData?.platformSettings?.billingEmail, + }), + [learnerHomeData], + ); const { formatMessage } = useIntl(); const formatDate = useFormatDate(); @@ -75,7 +94,7 @@ export const CertificateBanner = ({ cardId }) => { ); } - if (certificate.isEarnedButUnavailable) { + if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) { return ( {formatMessage( diff --git a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx index 4575a8aeb..993ba9943 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx @@ -1,20 +1,20 @@ +import React from 'react'; import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import CertificateBanner from './CertificateBanner'; jest.mock('hooks', () => ({ utilHooks: { useFormatDate: jest.fn(() => date => date), }, - reduxHooks: { - useCardCertificateData: jest.fn(), - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardGradeData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, + useCourseData: jest.fn(), +})); + +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), })); const defaultCertificate = { @@ -35,9 +35,14 @@ const supportEmail = 'suport@email.com'; const billingEmail = 'billing@email.com'; describe('CertificateBanner', () => { - reduxHooks.useCardCourseRunData.mockReturnValue({ - minPassingGrade: 0.8, - progressUrl: 'progressUrl', + useCourseData.mockReturnValue({ + enrollment: {}, + certificate: {}, + gradeData: {}, + courseRun: { + minPassingGrade: 0.8, + progressUrl: 'progressUrl', + }, }); const createWrapper = ({ certificate = {}, @@ -46,11 +51,17 @@ describe('CertificateBanner', () => { courseRun = {}, platformSettings = {}, }) => { - reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade }); - reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun }); - reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings }); + useCourseData.mockReturnValue({ + enrollment: { ...defaultEnrollment, ...enrollment }, + certificate: { ...defaultCertificate, ...certificate }, + gradeData: { ...defaultGrade, ...grade }, + courseRun: { + ...defaultCourseRun, + ...courseRun, + }, + }); + const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } }; + useInitializeLearnerHome.mockReturnValue(lernearData); return render(); }; beforeEach(() => { @@ -222,7 +233,8 @@ describe('CertificateBanner', () => { isPassing: true, }, certificate: { - isEarnedButUnavailable: true, + isEarned: true, + availableDate: '10/20/3030', }, }); const banner = screen.getByRole('alert'); @@ -239,4 +251,27 @@ describe('CertificateBanner', () => { const banner = screen.queryByRole('alert'); expect(banner).toBeNull(); }); + it('should use default values when courseData is empty or undefined', () => { + useCourseData.mockReturnValue({}); + const lernearData = { data: { platformSettings: { supportEmail } } }; + useInitializeLearnerHome.mockReturnValue(lernearData); + render(); + + const mockedUseMemo = jest.spyOn(React, 'useMemo'); + const useMemoCall = mockedUseMemo.mock.calls.find(call => call[1].some(dep => dep === undefined || dep === null)); + + if (useMemoCall) { + const result = useMemoCall[0](); + + expect(result.certificate).toEqual({}); + expect(result.isVerified).toBe(false); + expect(result.isAudit).toBe(false); + expect(result.isPassing).toBe(false); + expect(result.isArchived).toBe(false); + expect(result.minPassingGrade).toBe(0); + expect(result.progressUrl).toBeDefined(); + } + + mockedUseMemo.mockRestore(); + }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx index db26b1c00..0e3f9f6b8 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx @@ -1,21 +1,26 @@ /* eslint-disable max-len */ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { Hyperlink } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { utilHooks, reduxHooks } from 'hooks'; +import { utilHooks, useCourseData } from 'hooks'; import Banner from 'components/Banner'; import messages from './messages'; export const CourseBanner = ({ cardId }) => { + const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); const { - isVerified, - isAuditAccessExpired, + isVerified = false, + isAuditAccessExpired = false, coursewareAccess = {}, - } = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); - const { formatMessage } = useIntl(); + } = useMemo(() => ({ + isVerified: courseData.enrollment?.isVerified, + isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired, + coursewareAccess: courseData.enrollment?.coursewareAccess || {}, + }), [courseData]); + const courseRun = courseData?.courseRun || {}; const formatDate = utilHooks.useFormatDate(); const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx index 42d40331c..587d5e8a9 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx @@ -1,20 +1,17 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import { formatMessage } from 'testUtils'; import { CourseBanner } from './CourseBanner'; import messages from './messages'; jest.mock('hooks', () => ({ + useCourseData: jest.fn(), utilHooks: { useFormatDate: () => date => date, }, - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - }, })); const cardId = 'test-card-id'; @@ -39,13 +36,15 @@ const renderCourseBanner = (overrides = {}) => { courseRun = {}, enrollment = {}, } = overrides; - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - ...courseRunData, - ...courseRun, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - ...enrollmentData, - ...enrollment, + useCourseData.mockReturnValue({ + courseRun: { + ...courseRunData, + ...courseRun, + }, + enrollment: { + ...enrollmentData, + ...enrollment, + }, }); return render(); }; @@ -53,13 +52,20 @@ const renderCourseBanner = (overrides = {}) => { describe('CourseBanner', () => { it('initializes data with course number from enrollment, course and course run data', () => { renderCourseBanner(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); it('no display if learner is verified', () => { renderCourseBanner({ enrollment: { isVerified: true } }); expect(screen.queryByRole('alert')).toBeNull(); }); + it('should use default values when enrollment data is undefined', () => { + renderCourseBanner({ + enrollment: undefined, + courseRun: {}, + }); + + expect(useCourseData).toHaveBeenCalledWith('test-card-id'); + }); describe('audit access expired', () => { it('should display correct message and link', () => { renderCourseBanner({ enrollment: { isAuditAccessExpired: true } }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js index 89e92ebef..dc9ebb320 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js @@ -1,6 +1,8 @@ +import { useMemo } from 'react'; +import { useInitializeLearnerHome } from 'data/hooks'; import { StrictDict } from 'utils'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -15,9 +17,29 @@ export const statusComponents = StrictDict({ }); export const useCreditBannerData = (cardId) => { - const credit = reduxHooks.useCardCreditData(cardId); - const { supportEmail } = reduxHooks.usePlatformSettingsData(); - if (!credit.isEligible) { return null; } + const courseData = useCourseData(cardId); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const supportEmail = useMemo( + () => (learnerHomeData?.platformSettings?.supportEmail), + [learnerHomeData], + ); + + const credit = useMemo(() => { + const creditData = courseData?.credit; + if (!creditData || Object.keys(creditData).length === 0) { + return { isEligible: false }; + } + return { + isEligible: true, + providerStatusUrl: creditData.providerStatusUrl, + providerName: creditData.providerName, + providerId: creditData.providerId, + error: creditData.error, + purchased: creditData.purchased, + requestStatus: creditData.requestStatus, + }; + }, [courseData]); + if (!credit.isEligible || !courseData?.credit?.isEligible) { return null; } const { error, purchased, requestStatus } = credit; let ContentComponent = EligibleContent; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js index 729de8f7d..92d6341f0 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js @@ -1,5 +1,6 @@ import { keyStore } from 'utils'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -9,12 +10,19 @@ import RejectedContent from './views/RejectedContent'; import * as hooks from './hooks'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + jest.mock('hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, + useCourseData: jest.fn(), })); + +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + jest.mock('./views/ApprovedContent', () => 'ApprovedContent'); jest.mock('./views/EligibleContent', () => 'EligibleContent'); jest.mock('./views/MustRequestContent', () => 'MustRequestContent'); @@ -34,18 +42,18 @@ const defaultProps = { }; const loadHook = (creditData = {}) => { - reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData }); + useCourseData.mockReturnValue({ credit: { ...defaultProps, ...creditData } }); out = hooks.useCreditBannerData(cardId); }; describe('useCreditBannerData hook', () => { beforeEach(() => { - reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail }); + useInitializeLearnerHome.mockReturnValue({ data: { platformSettings: { supportEmail } } }); }); it('loads card credit data with cardID and loads platform settings data', () => { loadHook({ isEligible: false }); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.usePlatformSettingsData).toHaveBeenCalledWith(); + expect(useCourseData).toHaveBeenCalledWith(cardId); + expect(useInitializeLearnerHome).toHaveBeenCalledWith(); }); describe('non-credit-eligible learner', () => { it('returns null if the learner is not credit eligible', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx index bb334195c..af600851a 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx @@ -1,17 +1,24 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useIsMasquerading } from 'hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import messages from './messages'; export const ApprovedContent = ({ cardId }) => { - const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); - const { isMasquerading } = reduxHooks.useMasqueradeData(); + const courseData = useCourseData(cardId); + const { providerStatusUrl: href, providerName } = useMemo(() => { + const creditData = courseData?.credit; + return { + providerStatusUrl: creditData.providerStatusUrl, + providerName: creditData.providerName, + }; + }, [courseData]); + const isMasquerading = useIsMasquerading(); const { formatMessage } = useIntl(); return ( ({ - reduxHooks: { - useCardCreditData: jest.fn(), - useMasqueradeData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -17,14 +15,14 @@ const credit = { providerStatusUrl: 'test-credit-provider-status-url', providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); -reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); +useCourseData.mockReturnValue({ credit }); +useIsMasquerading.mockReturnValue(false); describe('ApprovedContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { render(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { @@ -56,7 +54,7 @@ describe('ApprovedContent component', () => { }); describe('when masquerading', () => { beforeEach(() => { - reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); + useIsMasquerading.mockReturnValue(true); render(); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx index b38fdec56..585df6ec7 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import track from 'tracking'; import CreditContent from './components/CreditContent'; @@ -11,8 +11,9 @@ import messages from './messages'; export const EligibleContent = ({ cardId }) => { const { formatMessage } = useIntl(); - const { providerName } = reduxHooks.useCardCreditData(cardId); - const { courseId } = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const providerName = courseData?.credit?.providerName; + const courseId = courseData?.courseRun?.courseId; const onClick = track.credit.purchase(courseId); const getCredit = formatMessage(messages.getCredit); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx index dfb7d4102..6b2d18d1e 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx @@ -2,17 +2,14 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import track from 'tracking'; import messages from './messages'; import EligibleContent from './EligibleContent'; jest.mock('hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + useCourseData: jest.fn(), })); jest.mock('tracking', () => ({ @@ -26,8 +23,7 @@ const courseId = 'test-course-id'; const credit = { providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); -reduxHooks.useCardCourseRunData.mockReturnValue({ courseId }); +useCourseData.mockReturnValue({ credit, courseRun: { courseId } }); const renderEligibleContent = () => render(); @@ -35,11 +31,7 @@ describe('EligibleContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { renderEligibleContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); - }); - it('initializes course run data with cardId', () => { - renderEligibleContent(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('behavior', () => { @@ -63,7 +55,7 @@ describe('EligibleContent component', () => { expect(eligibleMessage).toHaveTextContent(credit.providerName); }); it('message is formatted eligible message if no provider', () => { - reduxHooks.useCardCreditData.mockReturnValue({}); + useCourseData.mockReturnValue({ credit: {}, courseRun: { courseId } }); renderEligibleContent(); const eligibleMessage = screen.getByTestId('credit-msg'); expect(eligibleMessage).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx index 082e9143f..7ee5b57c5 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useIsMasquerading } from 'hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import hooks from './hooks'; @@ -13,7 +13,7 @@ import messages from './messages'; export const MustRequestContent = ({ cardId }) => { const { formatMessage } = useIntl(); const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId); - const { isMasquerading } = reduxHooks.useMasqueradeData(); + const isMasquerading = useIsMasquerading(); return ( ({ })); jest.mock('hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -44,10 +41,12 @@ describe('MustRequestContent component', () => { requestData, createCreditRequest, }); - reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); - reduxHooks.useCardCreditData.mockReturnValue({ - providerName, - providerStatusUrl, + useIsMasquerading.mockReturnValue(false); + useCourseData.mockReturnValue({ + credit: { + providerName, + providerStatusUrl, + }, }); }); @@ -90,7 +89,7 @@ describe('MustRequestContent component', () => { describe('when masquerading', () => { beforeEach(() => { - reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); + useIsMasquerading.mockReturnValue(true); renderMustRequestContent(); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx index b7b44dcb9..f45ec56a0 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx @@ -3,13 +3,14 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useIsMasquerading } from 'hooks'; import CreditContent from './components/CreditContent'; import messages from './messages'; export const PendingContent = ({ cardId }) => { - const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); - const { isMasquerading } = reduxHooks.useMasqueradeData(); + const courseData = useCourseData(cardId); + const { providerStatusUrl: href, providerName } = courseData?.credit || {}; + const isMasquerading = useIsMasquerading(); const { formatMessage } = useIntl(); return ( ({ - reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; const providerName = 'test-credit-provider-name'; const providerStatusUrl = 'test-credit-provider-status-url'; -reduxHooks.useCardCreditData.mockReturnValue({ - providerName, - providerStatusUrl, +useIsMasquerading.mockReturnValue(false); +useCourseData.mockReturnValue({ + credit: { + providerName, + providerStatusUrl, + }, }); -reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); const renderPendingContent = () => render( @@ -28,7 +30,7 @@ describe('PendingContent component', () => { describe('hooks', () => { it('initializes card credit data with cardId', () => { renderPendingContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('behavior', () => { @@ -56,7 +58,7 @@ describe('PendingContent component', () => { }); describe('when masqueradeData is true', () => { it('disables the view details button', () => { - reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); + useIsMasquerading.mockReturnValue(true); renderPendingContent(); const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage }); expect(button).toHaveClass('disabled'); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx index afd66e788..586919826 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx @@ -3,18 +3,19 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import messages from './messages'; export const RejectedContent = ({ cardId }) => { - const credit = reduxHooks.useCardCreditData(cardId); + const courseData = useCourseData(cardId); + const credit = courseData?.credit; const { formatMessage } = useIntl(); return ( ), })} /> diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx index 03df3ba30..eba071a95 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx @@ -1,13 +1,11 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import RejectedContent from './RejectedContent'; jest.mock('hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -15,7 +13,9 @@ const credit = { providerStatusUrl: 'test-credit-provider-status-url', providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); +useCourseData.mockReturnValue({ + credit, +}); const renderRejectedContent = () => render(); @@ -23,7 +23,7 @@ describe('RejectedContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { renderRejectedContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx index 74b20e92a..f484d2b26 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx @@ -2,11 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import { Hyperlink } from '@openedx/paragon'; export const ProviderLink = ({ cardId }) => { - const credit = reduxHooks.useCardCreditData(cardId); + const courseData = useCourseData(cardId); + const credit = courseData?.credit || {}; return ( ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -23,12 +21,12 @@ const renderProviderLink = () => render( describe('ProviderLink component', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardCreditData.mockReturnValue(credit); + useCourseData.mockReturnValue({ credit }); renderProviderLink(); }); describe('hooks', () => { it('initializes credit hook with cardId', () => { - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js index 81cffe90f..e787d21c4 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js @@ -1,7 +1,8 @@ import React from 'react'; - +import { AppContext } from '@edx/frontend-platform/react'; import { StrictDict } from 'utils'; -import { apiHooks } from 'hooks'; +import { useCourseData } from 'hooks'; +import { useCreateCreditRequest } from 'data/hooks'; import * as module from './hooks'; @@ -11,13 +12,19 @@ export const state = StrictDict({ export const useCreditRequestData = (cardId) => { const [requestData, setRequestData] = module.state.creditRequestData(null); - const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId); + const courseData = useCourseData(cardId); + const providerId = courseData?.credit?.providerId; + const { authenticatedUser: { username } } = React.useContext(AppContext); + const courseId = courseData?.courseRun?.courseId; + const { mutate: createCreditMutation } = useCreateCreditRequest(); + const createCreditRequest = (e) => { e.preventDefault(); - createCreditApiRequest() - .then((request) => { - setRequestData(request.data); - }); + createCreditMutation({ providerId, courseId, username }, { + onSuccess: (data) => { + setRequestData(data); + }, + }); }; return { requestData, createCreditRequest }; }; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js index d3e5c0695..077813ad8 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js @@ -1,19 +1,34 @@ import { MockUseState } from 'testUtils'; -import { apiHooks } from 'hooks'; +import { useCreateCreditRequest } from 'data/hooks'; import * as hooks from './hooks'; jest.mock('hooks', () => ({ - apiHooks: { - useCreateCreditRequest: jest.fn(), - }, + useCourseData: jest.fn().mockReturnValue({ + credit: { providerId: 'provider-123' }, + courseRun: { courseId: 'course-456' }, + }), })); +jest.mock('data/hooks', () => ({ + useCreateCreditRequest: jest.fn(), +})); + +jest.mock('react', () => { + const ActualReact = jest.requireActual('react'); + return { + ...ActualReact, + useContext: jest.fn().mockReturnValue({ + authenticatedUser: { username: 'test-user' }, + }), + }; +}); + const state = new MockUseState(hooks); const cardId = 'test-card-id'; const requestData = { data: 'request data' }; const creditRequest = jest.fn().mockReturnValue(Promise.resolve(requestData)); -apiHooks.useCreateCreditRequest.mockReturnValue(creditRequest); +useCreateCreditRequest.mockReturnValue({ mutate: creditRequest }); const event = { preventDefault: jest.fn() }; let out; @@ -31,7 +46,7 @@ describe('Credit Banner view hooks', () => { state.expectInitializedWith(state.keys.creditRequestData, null); }); it('calls useCreateCreditRequest with passed cardID', () => { - expect(apiHooks.useCreateCreditRequest).toHaveBeenCalledWith(cardId); + expect(useCreateCreditRequest).toHaveBeenCalledWith(); }); }); describe('output', () => { @@ -47,8 +62,7 @@ describe('Credit Banner view hooks', () => { }); it('calls api.createCreditRequest and sets requestData with the response', async () => { await out.createCreditRequest(event); - expect(creditRequest).toHaveBeenCalledWith(); - expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData.data); + expect(creditRequest).toHaveBeenCalled(); }); }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx index 16751846c..abd0aa367 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx @@ -1,16 +1,21 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, MailtoLink } from '@openedx/paragon'; -import { utilHooks, reduxHooks } from 'hooks'; - +import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks'; +import { useSelectSessionModal } from 'data/context'; import Banner from 'components/Banner'; +import { useInitializeLearnerHome } from 'data/hooks'; + import messages from './messages'; export const EntitlementBanner = ({ cardId }) => { const { formatMessage } = useIntl(); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const { isEntitlement, hasSessions, @@ -18,9 +23,12 @@ export const EntitlementBanner = ({ cardId }) => { changeDeadline, showExpirationWarning, isExpired, - } = reduxHooks.useCardEntitlementData(cardId); - const { supportEmail } = reduxHooks.usePlatformSettingsData(); - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + } = useEntitlementInfo(courseData); + const supportEmail = useMemo( + () => learnerHomeData?.platformSettings?.supportEmail, + [learnerHomeData], + ); + const { updateSelectSessionModal } = useSelectSessionModal(); const formatDate = utilHooks.useFormatDate(); if (!isEntitlement) { @@ -42,7 +50,7 @@ export const EntitlementBanner = ({ cardId }) => { {formatMessage(messages.entitlementExpiringSoon, { changeDeadline: formatDate(changeDeadline), selectSessionButton: ( - ), diff --git a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx index 898bef3c5..d838df924 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx @@ -1,22 +1,40 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { formatMessage } from 'testUtils'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import EntitlementBanner from './EntitlementBanner'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + platformSettings: { + supportEmail: 'test-support-email', + }, + }, + }), +})); +const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal'); +jest.mock('data/context/SelectSessionProvider', () => ({ + useSelectSessionModal: () => ({ + updateSelectSessionModal: mockUpdateSelectSessionModal, + }), +})); + jest.mock('hooks', () => ({ + ...jest.requireActual('hooks'), + useCourseData: jest.fn(), utilHooks: { - useFormatDate: () => date => date, - }, - reduxHooks: { - usePlatformSettingsData: jest.fn(), - useCardEntitlementData: jest.fn(), - useUpdateSelectSessionModalCallback: jest.fn( - (cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`), - ), + useFormatDate: () => date => date?.toDateString(), }, + })); const cardId = 'test-card-id'; @@ -32,16 +50,20 @@ const platformData = { supportEmail: 'test-support-email' }; const renderComponent = (overrides = {}) => { const { entitlement = {} } = overrides; - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement }); - reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData); + useCourseData.mockReturnValue({ + entitlement: { ...entitlementData, ...entitlement }, + platformSettings: platformData, + }); return render(); }; describe('EntitlementBanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('initializes data with course number from entitlement', () => { renderComponent(); - expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); it('no display if not an entitlement', () => { renderComponent({ entitlement: { isEntitlement: false } }); @@ -56,7 +78,10 @@ describe('EntitlementBanner', () => { expect(banner.innerHTML).toContain(platformData.supportEmail); }); it('renders when expiration warning', () => { - renderComponent({ entitlement: { showExpirationWarning: true } }); + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 4); + const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`; + renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } }); const banner = screen.getByRole('alert'); expect(banner).toBeInTheDocument(); expect(banner).toHaveClass('alert-info'); @@ -64,9 +89,37 @@ describe('EntitlementBanner', () => { expect(button).toBeInTheDocument(); }); it('renders expired banner', () => { - renderComponent({ entitlement: { isExpired: true } }); + renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } }); const banner = screen.getByRole('alert'); expect(banner).toBeInTheDocument(); expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired)); }); + it('should call updateSelectSessionModal with cardId when select session button is clicked', async () => { + const user = userEvent.setup(); + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 4); + const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`; + renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } }); + const banner = screen.getByRole('alert'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveClass('alert-info'); + const button = screen.getByRole('button', { name: formatMessage(messages.selectSession) }); + expect(button).toBeInTheDocument(); + await user.click(button); + + expect(mockUpdateSelectSessionModal).toHaveBeenCalledWith(cardId); + }); + it('should return null when isExpired is false and showExpirationWarning is false', () => { + renderComponent({ + entitlement: { + isEntitlement: true, + hasSessions: true, + isFulfilled: true, + showExpirationWarning: false, + isExpired: false, + }, + }); + const banner = screen.queryByRole('alert'); + expect(banner).toBeNull(); + }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx index a2400b177..0453e1f45 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { Program } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import Banner from 'components/Banner'; import ProgramList from './ProgramsList'; @@ -12,10 +12,10 @@ import messages from './messages'; export const RelatedProgramsBanner = ({ cardId }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + const programData = courseData?.programs; - const programData = reduxHooks.useCardRelatedProgramsData(cardId); - - if (!programData?.length) { + if (!courseData || !programData?.relatedPrograms.length) { return null; } @@ -27,7 +27,7 @@ export const RelatedProgramsBanner = ({ cardId }) => { {formatMessage(messages.relatedPrograms)} - + ); }; diff --git a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx index 160582082..e99f2dae6 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx @@ -1,13 +1,11 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import RelatedProgramsBanner from '.'; jest.mock('hooks', () => ({ - reduxHooks: { - useCardRelatedProgramsData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -27,21 +25,21 @@ const programData = { describe('RelatedProgramsBanner', () => { it('render empty', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue({}); + useCourseData.mockReturnValue(null); render(); const banner = screen.queryByRole('alert'); expect(banner).toBeNull(); }); it('render with programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); + useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } }); render(); const list = screen.getByRole('list'); expect(list.childElementCount).toBe(programData.list.length); }); it('render related programs title', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); + useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } }); render(); const title = screen.getByText('Related Programs:'); expect(title).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/index.jsx index ef05f1d4e..158f0b806 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import CourseBannerSlot from 'plugin-slots/CourseBannerSlot'; import CertificateBanner from './CertificateBanner'; @@ -10,7 +10,11 @@ import EntitlementBanner from './EntitlementBanner'; import RelatedProgramsBanner from './RelatedProgramsBanner'; export const CourseCardBanners = ({ cardId }) => { - const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId); + const courseData = useCourseData(cardId); + if (!courseData) { + return null; + } + const { isEnrolled = false } = courseData.enrollment; return (
diff --git a/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx index 90f273b44..a0d10ced6 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import CourseCardBanners from '.'; @@ -20,9 +20,11 @@ const mockedComponents = [ ]; jest.mock('hooks', () => ({ - reduxHooks: { - useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })), - }, + useCourseData: jest.fn(() => ({ + enrollment: { + isEnrolled: true, + }, + })), })); describe('CourseCardBanners', () => { @@ -36,8 +38,13 @@ describe('CourseCardBanners', () => { return expect(mockedComponent).toBeInTheDocument(); }); }); + it('render null with no courseData', () => { + useCourseData.mockReturnValue(null); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); it('render with isEnrolled false', () => { - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false }); + useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } }); render(); const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2); mockedComponentsIfNotEnrolled.map((componentName) => { diff --git a/src/containers/CourseCard/components/CourseCardDetails/hooks.js b/src/containers/CourseCard/components/CourseCardDetails/hooks.js index bcf285acf..863eb26bb 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.js @@ -1,20 +1,21 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import { utilHooks, reduxHooks } from 'hooks'; +import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks'; +import { useSelectSessionModal } from 'data/context'; import * as hooks from './hooks'; import messages from './messages'; export const useAccessMessage = ({ cardId }) => { const { formatMessage } = useIntl(); - const enrollment = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const { courseRun, enrollment } = courseData || {}; const formatDate = utilHooks.useFormatDate(); if (!courseRun.isStarted) { if (!courseRun.startDate && !courseRun.advertisedStart) { return null; } const startDate = courseRun.advertisedStart ? courseRun.advertisedStart : formatDate(courseRun.startDate); return formatMessage(messages.courseStarts, { startDate }); } - if (enrollment.isEnrolled) { + if (enrollment?.isEnrolled) { const { isArchived, endDate } = courseRun; const { accessExpirationDate, @@ -38,15 +39,15 @@ export const useAccessMessage = ({ cardId }) => { export const useCardDetailsData = ({ cardId }) => { const { formatMessage } = useIntl(); - const providerName = reduxHooks.useCardProviderData(cardId).name; - const { courseNumber } = reduxHooks.useCardCourseData(cardId); + const courseData = useCourseData(cardId); + const providerName = courseData?.courseProvider?.name; + const courseNumber = courseData?.course?.courseNumber; const { isEntitlement, isFulfilled, canChange, - } = reduxHooks.useCardEntitlementData(cardId); - - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + } = useEntitlementInfo(courseData); + const updateSelectSessionModal = useSelectSessionModal(); return { providerName: providerName || formatMessage(messages.unknownProviderName), @@ -54,7 +55,7 @@ export const useCardDetailsData = ({ cardId }) => { isEntitlement, isFulfilled, canChange, - openSessionModal, + openSessionModal: () => updateSelectSessionModal(cardId), courseNumber, changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton), }; diff --git a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js index 7d66991c4..bc487bf6b 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js @@ -1,23 +1,25 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { keyStore } from 'utils'; -import { utilHooks, reduxHooks } from 'hooks'; +import { utilHooks, useCourseData } from 'hooks'; import * as hooks from './hooks'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +jest.mock('data/context/SelectSessionProvider', () => ({ + useSelectSessionModal: jest.fn(() => jest.fn()), +})); jest.mock('hooks', () => ({ + ...jest.requireActual('hooks'), + useCourseData: jest.fn(), utilHooks: { useFormatDate: jest.fn(), }, - reduxHooks: { - useCardCourseData: jest.fn(), - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useCardProviderData: jest.fn(), - useUpdateSelectSessionModalCallback: (...args) => ({ updateSelectSessionModalCallback: args }), - }, })); jest.mock('@edx/frontend-platform/i18n', () => { @@ -60,15 +62,12 @@ describe('CourseCardDetails hooks', () => { const runHook = ({ provider = {}, entitlement = {} }) => { jest.spyOn(hooks, hookKeys.useAccessMessage) .mockImplementationOnce(mockAccessMessage); - reduxHooks.useCardProviderData.mockReturnValueOnce({ - ...providerData, - ...provider, - }); - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ - ...entitlementData, - ...entitlement, + useCourseData.mockReturnValue({ + courseProvider: { ...providerData, ...provider }, + course: { courseNumber }, + courseRun: {}, + entitlement: { ...entitlementData, ...entitlement }, }); - reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber }); out = hooks.useCardDetailsData({ cardId }); }; beforeEach(() => { @@ -101,21 +100,16 @@ describe('CourseCardDetails hooks', () => { endDate: '10/20/2000', }; const runHook = ({ enrollment = {}, courseRun = {} }) => { - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - ...courseRunData, - ...courseRun, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - ...enrollmentData, - ...enrollment, + useCourseData.mockReturnValue({ + courseRun: { ...courseRunData, ...courseRun }, + enrollment: { ...enrollmentData, ...enrollment }, }); out = hooks.useAccessMessage({ cardId }); }; it('loads data from enrollment and course run data based on course number', () => { runHook({}); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); describe('if not started yet', () => { diff --git a/src/containers/CourseCard/components/CourseCardImage.jsx b/src/containers/CourseCard/components/CourseCardImage.jsx index 97d22a78d..7bf9e26c6 100644 --- a/src/containers/CourseCard/components/CourseCardImage.jsx +++ b/src/containers/CourseCard/components/CourseCardImage.jsx @@ -1,11 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { baseAppUrl } from 'data/services/lms/urls'; import { Badge } from '@openedx/paragon'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; import verifiedRibbon from 'assets/verified-ribbon.png'; import useActionDisabledState from './hooks'; @@ -15,11 +16,10 @@ const { courseImageClicked } = track.course; export const CourseCardImage = ({ cardId, orientation }) => { const { formatMessage } = useIntl(); - const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const { isVerified } = reduxHooks.useCardEnrollmentData(cardId); + const courseData = useCourseData(cardId); + const { homeUrl } = courseData?.courseRun || {}; const { disableCourseTitle } = useActionDisabledState(cardId); - const handleImageClicked = reduxHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl); + const handleImageClicked = useCourseTrackingEvent(courseImageClicked, cardId, homeUrl); const wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`; const image = ( <> @@ -27,11 +27,11 @@ export const CourseCardImage = ({ cardId, orientation }) => { // w-100 is necessary for images on Safari, otherwise stretches full height of the image // https://stackoverflow.com/a/44250830 className="pgn__card-image-cap w-100 show" - src={bannerImgSrc} + src={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)} alt={formatMessage(messages.bannerAlt)} /> { - isVerified && ( + courseData?.enrollment?.isVerified && ( ({ - reduxHooks: { - useCardCourseData: jest.fn(() => ({ bannerImgSrc })), - useCardCourseRunData: jest.fn(() => ({ homeUrl })), - useCardEnrollmentData: jest.fn(), - useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({ - trackCourseEvent: { eventName, cardId, url }, - })), - }, + useCourseData: jest.fn(() => ({ + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: {}, + })), + useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({ + trackCourseEvent: { eventName, cardId, url }, + })), })); jest.mock('./hooks', () => jest.fn()); @@ -30,7 +30,13 @@ describe('CourseCardImage', () => { it('renders course image with correct attributes', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: true }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, + ); render(); const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) }); @@ -41,7 +47,13 @@ describe('CourseCardImage', () => { it('isVerified, should render badge', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, + ); render(); const badge = screen.getByText(formatMessage(messages.verifiedBanner)); @@ -52,7 +64,13 @@ describe('CourseCardImage', () => { it('renders link with correct href if disableCourseTitle is false', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: false }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: false }, + }, + ); render(); const link = screen.getByRole('link'); @@ -61,12 +79,15 @@ describe('CourseCardImage', () => { describe('hooks', () => { it('initializes', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); - render(); - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith( - props.cardId, + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, ); + render(); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); }); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx index a1ea2145a..3a91fd176 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx @@ -1,13 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as ReactShare from 'react-share'; - +import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Dropdown } from '@openedx/paragon'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; - +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks'; +import { useCardSocialSettingsData } from './hooks'; import messages from './messages'; export const testIds = { @@ -16,14 +16,15 @@ export const testIds = { export const SocialShareMenu = ({ cardId, emailSettings }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + const courseName = courseData?.course?.courseName; + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; + const { twitter, facebook } = useCardSocialSettingsData(cardId); + const isMasquerading = useIsMasquerading(); - const { courseName } = reduxHooks.useCardCourseData(cardId); - const { isEmailEnabled, isExecEd2UCourse } = reduxHooks.useCardEnrollmentData(cardId); - const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId); - const { isMasquerading } = reduxHooks.useMasqueradeData(); - - const handleTwitterShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'twitter'); - const handleFacebookShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'facebook'); + const handleTwitterShare = useCourseTrackingEvent(track.socialShare, cardId, 'twitter'); + const handleFacebookShare = useCourseTrackingEvent(track.socialShare, cardId, 'facebook'); if (isExecEd2UCourse) { return null; diff --git a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx index 7c89421ac..16bd626bb 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx @@ -4,9 +4,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { render, screen } from '@testing-library/react'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks'; -import { useEmailSettings } from './hooks'; +import { useEmailSettings, useCardSocialSettingsData } from './hooks'; import SocialShareMenu from './SocialShareMenu'; import messages from './messages'; @@ -15,16 +15,13 @@ jest.mock('tracking', () => ({ })); jest.mock('hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardCourseData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardSocialSettingsData: jest.fn(), - useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })), + useIsMasquerading: jest.fn(), })); jest.mock('./hooks', () => ({ useEmailSettings: jest.fn(), + useCardSocialSettingsData: jest.fn(), })); const props = { @@ -57,23 +54,25 @@ const socialShare = { const mockHooks = (returnVals = {}) => { mockHook( - reduxHooks.useCardEnrollmentData, + useCourseData, { - isEmailEnabled: !!returnVals.isEmailEnabled, - isExecEd2UCourse: !!returnVals.isExecEd2UCourse, + enrollment: { + isEmailEnabled: !!returnVals.isEmailEnabled, + mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard', + }, + course: { courseName }, }, { isCardHook: true }, ); - mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true }); - mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading }); mockHook( - reduxHooks.useCardSocialSettingsData, + useCardSocialSettingsData, { facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled }, twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled }, }, { isCardHook: true }, ); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); }; const renderComponent = () => render(); @@ -87,13 +86,12 @@ describe('SocialShareMenu', () => { it('initializes local hooks', () => { when(useEmailSettings).expectCalledWith(); }); - it('initializes redux hook data ', () => { - when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); - when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId); - when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId); - when(reduxHooks.useMasqueradeData).expectCalledWith(); - when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter'); - when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook'); + it('initializes hook data ', () => { + when(useCourseData).expectCalledWith(props.cardId); + when(useCardSocialSettingsData).expectCalledWith(props.cardId); + when(useIsMasquerading).expectCalledWith(); + when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter'); + when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook'); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardMenu/hooks.js b/src/containers/CourseCard/components/CourseCardMenu/hooks.js index 8b2897357..d2e04f95a 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/hooks.js +++ b/src/containers/CourseCard/components/CourseCardMenu/hooks.js @@ -1,7 +1,8 @@ import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; import { useState } from 'react'; import { StrictDict } from 'utils'; +import { useInitializeLearnerHome } from 'data/hooks'; export const state = StrictDict({ isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line @@ -27,7 +28,7 @@ export const useEmailSettings = () => { }; export const useHandleToggleDropdown = (cardId) => { - const trackCourseEvent = reduxHooks.useTrackCourseEvent( + const trackCourseEvent = useCourseTrackingEvent( track.course.courseOptionsDropdownClicked, cardId, ); @@ -36,10 +37,30 @@ export const useHandleToggleDropdown = (cardId) => { }; }; +export const useCardSocialSettingsData = (cardId) => { + const { data: learnerHomeData } = useInitializeLearnerHome(); + const { data: courseData } = useCourseData(cardId); + const socialShareSettings = learnerHomeData?.socialShareSettings; + const { socialShareUrl } = courseData?.course || {}; + const defaultSettings = { isEnabled: false, shareUrl: '' }; + + if (!socialShareSettings) { + return { facebook: defaultSettings, twitter: defaultSettings }; + } + const { facebook, twitter } = socialShareSettings; + const loadSettings = (target) => ({ + isEnabled: target.isEnabled, + shareUrl: `${socialShareUrl}?${target.utmParams}`, + }); + return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) }; +}; + export const useOptionVisibility = (cardId) => { - const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId); - const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId); - const { isEarned } = reduxHooks.useCardCertificateData(cardId); + const courseData = useCourseData(cardId); + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; + const isEnrolled = courseData?.enrollment?.isEnrolled ?? false; + const { twitter, facebook } = useCardSocialSettingsData(cardId); + const isEarned = courseData?.certificate?.isEarned ?? false; const shouldShowUnenrollItem = isEnrolled && !isEarned; const shouldShowDropdown = ( diff --git a/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js b/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js index b423cc3f0..49b9f381b 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js @@ -1,20 +1,21 @@ -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; +import { useInitializeLearnerHome } from 'data/hooks'; import track from 'tracking'; import { MockUseState } from 'testUtils'; import * as hooks from './hooks'; +jest.mock('data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + jest.mock('hooks', () => ({ - reduxHooks: { - useCardCertificateData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardSocialSettingsData: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn(), })); const trackCourseEvent = jest.fn(); -reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent); +useCourseTrackingEvent.mockReturnValue(trackCourseEvent); const cardId = 'test-card-id'; let out; @@ -71,7 +72,7 @@ describe('CourseCardMenu hooks', () => { beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); }); describe('behavior', () => { it('initializes course event tracker with event name and card ID', () => { - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.courseOptionsDropdownClicked, cardId, ); @@ -88,55 +89,61 @@ describe('CourseCardMenu hooks', () => { }); describe('useOptionVisibility', () => { - const mockReduxHooks = (returnVals = {}) => { - reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({ - facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, - twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - isEnrolled: !!returnVals.isEnrolled, - isEmailEnabled: !!returnVals.isEmailEnabled, - }); - reduxHooks.useCardCertificateData.mockReturnValueOnce({ - isEarned: !!returnVals.isEarned, + const mockHooks = (returnVals = {}) => { + useInitializeLearnerHome.mockReturnValue({ + data: { + socialShareSettings: { + facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, + twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, + }, + }, + }); + useCourseData.mockReturnValue({ + enrollment: { + isEnrolled: !!returnVals.isEnrolled, + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + certificate: { + isEarned: !!returnVals.isEarned, + }, }); }; describe('shouldShowUnenrollItem', () => { it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true); }); it('returns false if not enrolled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); it('returns false if enrolled but also earned', () => { - mockReduxHooks({ isEarned: true }); + mockHooks({ isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); }); describe('shouldShowDropdown', () => { it('returns false if not enrolled and both email and socials are disabled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns false if enrolled but already earned, and both email and socials are disabled', () => { - mockReduxHooks({ isEnrolled: true, isEarned: true }); + mockHooks({ isEnrolled: true, isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns true if either social is enabled', () => { - mockReduxHooks({ facebook: { isEnabled: true } }); + mockHooks({ facebook: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); - mockReduxHooks({ twitter: { isEnabled: true } }); + mockHooks({ twitter: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if email is enabled', () => { - mockReduxHooks({ isEmailEnabled: true }); + mockHooks({ isEmailEnabled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.jsx index 918ef37fa..7f202bb17 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.jsx @@ -6,7 +6,7 @@ import { MoreVert } from '@openedx/paragon/icons'; import EmailSettingsModal from 'containers/EmailSettingsModal'; import UnenrollConfirmModal from 'containers/UnenrollConfirmModal'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useIsMasquerading } from 'hooks'; import SocialShareMenu from './SocialShareMenu'; import { useEmailSettings, @@ -23,13 +23,15 @@ export const testIds = { export const CourseCardMenu = ({ cardId }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; const emailSettings = useEmailSettings(); const unenrollModal = useUnenrollData(); const handleToggleDropdown = useHandleToggleDropdown(cardId); const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId); - const { isMasquerading } = reduxHooks.useMasqueradeData(); - const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId); + const isMasquerading = useIsMasquerading(); if (!shouldShowDropdown) { return null; diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx index 9d07bffb8..4db206d8a 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx @@ -4,16 +4,14 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useIsMasquerading } from 'hooks'; import * as hooks from './hooks'; import CourseCardMenu from '.'; import messages from './messages'; jest.mock('hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardEnrollmentData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); jest.mock('./SocialShareMenu', () => jest.fn(() =>
SocialShareMenu
)); jest.mock('containers/EmailSettingsModal', () => jest.fn(() =>
EmailSettingsModal
)); @@ -69,10 +67,14 @@ const mockHooks = (returnVals = {}) => { }, { isCardHook: true }, ); - mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading }); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); mockHook( - reduxHooks.useCardEnrollmentData, - { isEmailEnabled: !!returnVals.isEmailEnabled }, + useCourseData, + { + enrollment: { + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + }, { isCardHook: true }, ); }; @@ -87,13 +89,10 @@ describe('CourseCardMenu', () => { }); it('initializes local hooks', () => { when(hooks.useEmailSettings).expectCalledWith(); - when(hooks.useUnenrollData).expectCalledWith(); - when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId); - when(hooks.useOptionVisibility).expectCalledWith(props.cardId); }); - it('initializes redux hook data ', () => { - when(reduxHooks.useMasqueradeData).expectCalledWith(); - when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); + it('initializes hook data ', () => { + when(useIsMasquerading).expectCalledWith(); + when(useCourseData).expectCalledWith(props.cardId); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardTitle.jsx b/src/containers/CourseCard/components/CourseCardTitle.jsx index 5d7bc7c21..80fec1cde 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.jsx @@ -2,15 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import track from 'tracking'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; import useActionDisabledState from './hooks'; const { courseTitleClicked } = track.course; export const CourseCardTitle = ({ cardId }) => { - const { courseName } = reduxHooks.useCardCourseData(cardId); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const handleTitleClicked = reduxHooks.useTrackCourseEvent( + const courseData = useCourseData(cardId); + const courseName = courseData?.course?.courseName; + const homeUrl = courseData?.courseRun?.homeUrl; + const handleTitleClicked = useCourseTrackingEvent( courseTitleClicked, cardId, homeUrl, diff --git a/src/containers/CourseCard/components/CourseCardTitle.test.jsx b/src/containers/CourseCard/components/CourseCardTitle.test.jsx index 6d6245199..d8869e0f2 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.test.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.test.jsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { reduxHooks } from 'hooks'; +import { useCourseData, useCourseTrackingEvent } from 'hooks'; import track from 'tracking'; import useActionDisabledState from './hooks'; import CourseCardTitle from './CourseCardTitle'; @@ -12,11 +12,8 @@ jest.mock('tracking', () => ({ })); jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardCourseRunData: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn(), })); jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false }))); @@ -32,9 +29,11 @@ describe('CourseCardTitle', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardCourseData.mockReturnValue({ courseName }); - reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl }); - reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick); + useCourseData.mockReturnValue({ + course: { courseName }, + courseRun: { homeUrl }, + }); + useCourseTrackingEvent.mockReturnValue(handleTitleClick); }); it('renders course name as link when not disabled', async () => { @@ -62,9 +61,8 @@ describe('CourseCardTitle', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); render(); - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseData).toHaveBeenCalledWith(props.cardId); + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.courseTitleClicked, props.cardId, homeUrl, diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx index 2b03dacdb..3c122f8ef 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StrictDict } from 'utils'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import messages from './messages'; import * as module from './hooks'; @@ -14,7 +14,8 @@ export const state = StrictDict({ export const useRelatedProgramsBadgeData = ({ cardId }) => { const [isOpen, setIsOpen] = module.state.isOpen(false); const { formatMessage } = useIntl(); - const numPrograms = reduxHooks.useCardRelatedProgramsData(cardId).length; + const courseData = useCourseData(cardId); + const numPrograms = courseData?.programs?.relatedPrograms?.length || 0; let programsMessage = ''; if (numPrograms) { programsMessage = formatMessage( diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js index eaa8d6b82..babae7f23 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js @@ -1,15 +1,13 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { MockUseState } from 'testUtils'; -import { reduxHooks } from 'hooks'; +import { useCourseData } from 'hooks'; import * as hooks from './hooks'; import messages from './messages'; jest.mock('hooks', () => ({ - reduxHooks: { - useCardRelatedProgramsData: jest.fn(), - }, + useCourseData: jest.fn(), })); jest.mock('@edx/frontend-platform/i18n', () => { @@ -39,8 +37,10 @@ describe('RelatedProgramsBadge hooks', () => { describe('useRelatedProgramsBadgeData', () => { beforeEach(() => { state.mock(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ - length: numPrograms, + useCourseData.mockReturnValue({ + programs: { + relatedPrograms: new Array(numPrograms).fill({}), + }, }); out = hooks.useRelatedProgramsBadgeData({ cardId }); }); @@ -64,12 +64,12 @@ describe('RelatedProgramsBadge hooks', () => { expect(out.numPrograms).toEqual(numPrograms); }); test('returns empty programsMessage if no programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 }); + useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } }); out = hooks.useRelatedProgramsBadgeData({ cardId }); expect(out.programsMessage).toEqual(''); }); test('returns badgeLabelSingular programsMessage if 1 programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 }); + useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } }); out = hooks.useRelatedProgramsBadgeData({ cardId }); expect(out.programsMessage).toEqual(formatMessage( messages.badgeLabelSingular, diff --git a/src/containers/CourseCard/components/hooks.js b/src/containers/CourseCard/components/hooks.js index 9d80c0ae9..15c1cd3a5 100644 --- a/src/containers/CourseCard/components/hooks.js +++ b/src/containers/CourseCard/components/hooks.js @@ -1,16 +1,19 @@ -import { reduxHooks } from 'hooks'; +import { useCourseData, useEntitlementInfo, useIsMasquerading } from 'hooks'; export const useActionDisabledState = (cardId) => { - const { isMasquerading } = reduxHooks.useMasqueradeData(); + const courseData = useCourseData(cardId); + const isMasquerading = useIsMasquerading(); + const { - hasAccess, isAudit, isAuditAccessExpired, - } = reduxHooks.useCardEnrollmentData(cardId); + isAudit, isAuditAccessExpired, + } = courseData.enrollment || {}; + const { isStaff, hasUnmetPrereqs, isTooEarly } = courseData.enrollment?.coursewareAccess || {}; + const hasAccess = isStaff || !(hasUnmetPrereqs || isTooEarly); const { isEntitlement, isFulfilled, canChange, hasSessions, - } = reduxHooks.useCardEntitlementData(cardId); - - const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId); + } = useEntitlementInfo(courseData); + const { resumeUrl, homeUrl } = courseData.courseRun || {}; const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired); diff --git a/src/containers/CourseCard/components/hooks.test.js b/src/containers/CourseCard/components/hooks.test.js index 50d2ccc25..08bb00967 100644 --- a/src/containers/CourseCard/components/hooks.test.js +++ b/src/containers/CourseCard/components/hooks.test.js @@ -1,14 +1,15 @@ -import { reduxHooks } from 'hooks'; - +import { useCourseData, useIsMasquerading } from 'hooks'; import * as hooks from './hooks'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: jest.fn((fn) => fn()), +})); + jest.mock('hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + ...jest.requireActual('hooks'), + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'my-test-course-number'; @@ -38,25 +39,38 @@ describe('useActionDisabledState', () => { isAuditAccessExpired, resumeUrl, homeUrl, + availableSessions, } = { ...defaultData, ...args }; - reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - hasAccess, - isAudit, - isAuditAccessExpired, - }); - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ - isEntitlement, - isFulfilled, - canChange, - hasSessions, - }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - resumeUrl, - homeUrl, + useIsMasquerading.mockReturnValue(isMasquerading); + useCourseData.mockReturnValue({ + enrollment: { + hasAccess, + isAudit, + isAuditAccessExpired, + coursewareAccess: { + isStaff: false, + hasUnmetPrereqs: !hasAccess, + isTooEarly: !hasAccess, + }, + }, + entitlement: isEntitlement ? { + isEntitlement: true, + isFulfilled, + canChange, + hasSessions, + availableSessions, + } : {}, + courseRun: { + resumeUrl, + homeUrl, + }, }); }; + beforeEach(() => { + jest.clearAllMocks(); + }); + const runHook = () => hooks.useActionDisabledState(cardId); describe('disableBeginCourse', () => { const testDisabled = (data, expected) => { @@ -142,6 +156,7 @@ describe('useActionDisabledState', () => { hasAccess: true, canChange: true, hasSessions: true, + availableSessions: ['session1'], }, false, ); diff --git a/src/containers/CourseCard/hooks.js b/src/containers/CourseCard/hooks.js index 9a0f5ca77..76973d5bf 100644 --- a/src/containers/CourseCard/hooks.js +++ b/src/containers/CourseCard/hooks.js @@ -1,23 +1,6 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; import { useWindowSize, breakpoints } from '@openedx/paragon'; -import { reduxHooks } from 'hooks'; export const useIsCollapsed = () => { const { width } = useWindowSize(); return width < breakpoints.small.maxWidth; }; - -export const useCardData = ({ cardId }) => { - const { formatMessage } = useIntl(); - const { title, bannerImgSrc } = reduxHooks.useCardCourseData(cardId); - const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId); - - return { - isEnrolled, - title, - bannerImgSrc, - formatMessage, - }; -}; - -export default useCardData; diff --git a/src/containers/CourseCard/hooks.test.js b/src/containers/CourseCard/hooks.test.js index 86f89b697..9010873c2 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -1,58 +1,32 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { renderHook } from '@testing-library/react'; +import { useWindowSize } from '@openedx/paragon'; +import { useIsCollapsed } from './hooks'; -import { reduxHooks } from 'hooks'; - -import * as hooks from './hooks'; - -jest.mock('hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardEnrollmentData: jest.fn(), +jest.mock('@openedx/paragon', () => ({ + useWindowSize: jest.fn(), + breakpoints: { + small: { + maxWidth: 576, + }, }, })); -jest.mock('@edx/frontend-platform/i18n', () => { - const { formatMessage } = jest.requireActual('testUtils'); - return { - ...jest.requireActual('@edx/frontend-platform/i18n'), - useIntl: () => ({ - formatMessage, - }), - }; -}); - -const cardId = 'my-test-course-number'; - -describe('CourseCard hooks', () => { - let out; - const { formatMessage } = useIntl(); - beforeEach(() => { +describe('useIsCollapsed', () => { + afterEach(() => { jest.clearAllMocks(); }); - describe('useCardData', () => { - const courseData = { - title: 'fake-title', - bannerImgSrc: 'my-banner-url', - }; - const runHook = ({ course = {} }) => { - reduxHooks.useCardCourseData.mockReturnValueOnce({ - ...courseData, - ...course, - }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: 'test-is-enrolled' }); - out = hooks.useCardData({ cardId }); - }; - beforeEach(() => { - runHook({}); - }); - it('forwards formatMessage from useIntl', () => { - expect(out.formatMessage).toEqual(formatMessage); - }); - it('passes course title and banner URL form course data', () => { - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(cardId); - expect(out.title).toEqual(courseData.title); - expect(out.bannerImgSrc).toEqual(courseData.bannerImgSrc); - }); + it('should return true when window width is smaller than small breakpoint', () => { + useWindowSize.mockReturnValue({ width: 500 }); + const { result } = renderHook(() => useIsCollapsed()); + expect(result.current).toBe(true); + expect(useWindowSize).toHaveBeenCalled(); + }); + + it('should return false when window width is larger than small breakpoint', () => { + useWindowSize.mockReturnValue({ width: 800 }); + const { result } = renderHook(() => useIsCollapsed()); + expect(result.current).toBe(false); + expect(useWindowSize).toHaveBeenCalled(); }); }); diff --git a/src/containers/CourseFilterControls/ActiveCourseFilters.jsx b/src/containers/CourseFilterControls/ActiveCourseFilters.jsx index a121c749c..e25f57893 100644 --- a/src/containers/CourseFilterControls/ActiveCourseFilters.jsx +++ b/src/containers/CourseFilterControls/ActiveCourseFilters.jsx @@ -1,27 +1,24 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Chip } from '@openedx/paragon'; import { CloseSmall } from '@openedx/paragon/icons'; -import { reduxHooks } from 'hooks'; +import { useFilters } from 'data/context'; import messages from './messages'; import './index.scss'; -export const ActiveCourseFilters = ({ - filters, - handleRemoveFilter, -}) => { +export const ActiveCourseFilters = () => { const { formatMessage } = useIntl(); - const clearFilters = reduxHooks.useClearFilters(); + const { filters, clearFilters, removeFilter } = useFilters(); + return (
{filters.map(filter => ( removeFilter(filter)} > {formatMessage(messages[filter])} @@ -32,9 +29,5 @@ export const ActiveCourseFilters = ({
); }; -ActiveCourseFilters.propTypes = { - filters: PropTypes.arrayOf(PropTypes.string).isRequired, - handleRemoveFilter: PropTypes.func.isRequired, -}; export default ActiveCourseFilters; diff --git a/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx b/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx index aded20850..c499515f2 100644 --- a/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx +++ b/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx @@ -1,28 +1,54 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { formatMessage } from 'testUtils'; +import { useFilters } from 'data/context'; import { FilterKeys } from 'data/constants/app'; +import userEvent from '@testing-library/user-event'; import ActiveCourseFilters from './ActiveCourseFilters'; import messages from './messages'; const filters = Object.values(FilterKeys); +jest.mock('data/context', () => ({ + useFilters: jest.fn(), +})); + +const removeFiltersMock = jest.fn().mockName('removeFilter'); +const clearFiltersMock = jest.fn().mockName('clearFilters'); +useFilters.mockReturnValue({ + filters, + removeFilter: removeFiltersMock, + clearFilters: clearFiltersMock, +}); + describe('ActiveCourseFilters', () => { - const props = { - filters, - handleRemoveFilter: jest.fn().mockName('handleRemoveFilter'), - }; it('renders chips correctly', () => { - render(); + render(); filters.map((key) => { const chip = screen.getByText(formatMessage(messages[key])); return expect(chip).toBeInTheDocument(); }); }); it('renders button correctly', () => { - render(); + render(); const button = screen.getByRole('button', { name: formatMessage(messages.clearAll) }); expect(button).toBeInTheDocument(); }); + it('should call onClick when button is clicked remove filter', async () => { + const user = userEvent.setup(); + render(); + const removeButton = screen.getByRole('button', { name: formatMessage(messages[filters[0]]) }); + await user.click(removeButton); + expect(removeFiltersMock).toHaveBeenCalledTimes(1); + expect(removeFiltersMock).toHaveBeenCalledWith(filters[0]); + }); + it('should call onClick when button is clicked clear all filters', async () => { + const user = userEvent.setup(); + render(); + screen.debug(); + const clearAllButton = screen.getByRole('button', { name: formatMessage(messages.clearAll) }); + await user.click(clearAllButton); + expect(clearFiltersMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/containers/CourseFilterControls/CourseFilterControls.jsx b/src/containers/CourseFilterControls/CourseFilterControls.jsx index 4dd981865..5a28e2c6c 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -14,44 +13,45 @@ import { } from '@openedx/paragon'; import { Close, Tune } from '@openedx/paragon/icons'; -import { reduxHooks } from 'hooks'; - +import { useInitializeLearnerHome } from 'data/hooks'; +import { useFilters } from 'data/context'; import FilterForm from './components/FilterForm'; import SortForm from './components/SortForm'; -import useCourseFilterControlsData from './hooks'; import messages from './messages'; import './index.scss'; -export const CourseFilterControls = ({ - sortBy, - setSortBy, - filters, -}) => { +export const CourseFilterControls = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [targetRef, setTargetRef] = React.useState(null); const { formatMessage } = useIntl(); - const hasCourses = reduxHooks.useHasCourses(); + const { data } = useInitializeLearnerHome(); + const hasCourses = React.useMemo(() => data?.courses?.length > 0, [data]); const { - isOpen, - open, - close, - target, - setTarget, - handleFilterChange, - handleSortChange, - } = useCourseFilterControlsData({ - filters, - setSortBy, - }); + filters, sortBy, setSortBy, addFilter, removeFilter, + } = useFilters(); + + const openFiltersOptions = () => setIsOpen(true); + const closeFiltersOptions = () => setIsOpen(false); + + const handleSortChange = (event) => { + setSortBy(event.target.value); + }; + + const handleFilterChange = ({ target: { checked, value } }) => { + const update = checked ? addFilter : removeFilter; + update(value); + }; const { width } = useWindowSize(); const isMobile = width < breakpoints.small.minWidth; return (