From 0d2eb96c867e7b2fdbccfbe9b71725ab5c6c580b Mon Sep 17 00:00:00 2001 From: Jacobo Dominguez Date: Tue, 3 Mar 2026 09:45:57 -0600 Subject: [PATCH] React query and react context conversion (#786) Migrate from Redux to React Query and React Context. This modernizes state management while maintaining all existing functionality. All the redux code and files were removed, including all redux and related packages. --- .env | 1 + .env.development | 1 + .env.test | 1 + example.env.config.js | 1 + package-lock.json | 184 +--- package.json | 11 +- src/App.jsx | 48 +- src/App.test.jsx | 45 +- src/config/index.js | 1 + src/containers/AppWrapper/index.test.tsx | 15 + .../CourseCardActions/BeginCourseButton.jsx | 18 +- .../BeginCourseButton.test.jsx | 48 +- .../CourseCardActions/ResumeButton.jsx | 18 +- .../CourseCardActions/ResumeButton.test.jsx | 50 +- .../CourseCardActions/SelectSessionButton.jsx | 6 +- .../SelectSessionButton.test.jsx | 16 +- .../CourseCardActions/ViewCourseButton.jsx | 7 +- .../ViewCourseButton.test.jsx | 22 +- .../components/CourseCardActions/index.jsx | 11 +- .../CourseCardActions/index.test.jsx | 35 +- .../CourseCardBanners/CertificateBanner.jsx | 41 +- .../CertificateBanner.test.jsx | 69 +- .../CourseCardBanners/CourseBanner.jsx | 21 +- .../CourseCardBanners/CourseBanner.test.jsx | 34 +- .../CourseCardBanners/CreditBanner/hooks.js | 30 +- .../CreditBanner/hooks.test.js | 28 +- .../CreditBanner/views/ApprovedContent.jsx | 15 +- .../views/ApprovedContent.test.jsx | 16 +- .../CreditBanner/views/EligibleContent.jsx | 7 +- .../views/EligibleContent.test.jsx | 18 +- .../CreditBanner/views/MustRequestContent.jsx | 4 +- .../views/MustRequestContent.test.jsx | 21 +- .../CreditBanner/views/PendingContent.jsx | 7 +- .../views/PendingContent.test.jsx | 20 +- .../CreditBanner/views/RejectedContent.jsx | 7 +- .../views/RejectedContent.test.jsx | 12 +- .../views/components/ProviderLink.jsx | 5 +- .../views/components/ProviderLink.test.jsx | 10 +- .../CreditBanner/views/hooks.js | 21 +- .../CreditBanner/views/hooks.test.js | 56 -- .../CreditBanner/views/hooks.test.tsx | 192 ++++ .../CourseCardBanners/EntitlementBanner.jsx | 22 +- .../EntitlementBanner.test.jsx | 83 +- .../RelatedProgramsBanner/index.jsx | 10 +- .../RelatedProgramsBanner/index.test.jsx | 12 +- .../components/CourseCardBanners/index.jsx | 8 +- .../CourseCardBanners/index.test.jsx | 17 +- .../components/CourseCardDetails/hooks.js | 21 +- .../CourseCardDetails/hooks.test.js | 54 +- .../CourseCard/components/CourseCardImage.jsx | 14 +- .../components/CourseCardImage.test.jsx | 55 +- .../CourseCardMenu/SocialShareMenu.jsx | 21 +- .../CourseCardMenu/SocialShareMenu.test.jsx | 42 +- .../components/CourseCardMenu/hooks.js | 31 +- .../components/CourseCardMenu/hooks.test.js | 63 +- .../components/CourseCardMenu/index.jsx | 8 +- .../components/CourseCardMenu/index.test.jsx | 27 +- .../CourseCard/components/CourseCardTitle.jsx | 9 +- .../components/CourseCardTitle.test.jsx | 22 +- .../components/RelatedProgramsBadge/hooks.jsx | 5 +- .../RelatedProgramsBadge/hooks.test.js | 16 +- src/containers/CourseCard/components/hooks.js | 17 +- .../CourseCard/components/hooks.test.js | 61 +- src/containers/CourseCard/hooks.js | 17 - src/containers/CourseCard/hooks.test.js | 72 +- .../ActiveCourseFilters.jsx | 17 +- .../ActiveCourseFilters.test.jsx | 38 +- .../CourseFilterControls.jsx | 67 +- .../CourseFilterControls.test.jsx | 157 +++- src/containers/CourseFilterControls/hooks.js | 60 -- .../CourseFilterControls/hooks.test.js | 122 --- .../CoursesPanel/CourseList/index.jsx | 6 +- .../CoursesPanel/NoCoursesView/index.jsx | 5 +- .../CoursesPanel/NoCoursesView/index.test.jsx | 14 +- src/containers/CoursesPanel/hooks.js | 54 -- src/containers/CoursesPanel/hooks.test.js | 115 --- src/containers/CoursesPanel/index.jsx | 46 +- src/containers/CoursesPanel/index.test.jsx | 138 ++- .../Dashboard/DashboardLayout.test.jsx | 10 + src/containers/Dashboard/hooks.js | 7 - src/containers/Dashboard/hooks.test.js | 21 - src/containers/Dashboard/index.jsx | 19 +- src/containers/Dashboard/index.test.jsx | 21 +- src/containers/EmailSettingsModal/hooks.js | 11 +- .../EmailSettingsModal/hooks.test.js | 71 -- .../EmailSettingsModal/hooks.test.jsx | 134 +++ .../ConfirmEmailBanner/hooks.js | 8 +- .../ConfirmEmailBanner/hooks.test.js | 77 -- .../ConfirmEmailBanner/hooks.test.jsx | 111 +++ .../LearnerDashboardHeader/index.jsx | 5 +- .../LearnerDashboardHeader/index.test.jsx | 14 +- src/containers/MasqueradeBar/hooks.js | 69 -- src/containers/MasqueradeBar/hooks.test.js | 112 --- src/containers/MasqueradeBar/index.jsx | 47 +- src/containers/MasqueradeBar/index.test.jsx | 167 +++- src/containers/RelatedProgramsModal/hooks.js | 10 - src/containers/RelatedProgramsModal/index.jsx | 7 +- .../RelatedProgramsModal/index.test.jsx | 21 +- src/containers/SelectSessionModal/hooks.js | 39 +- .../SelectSessionModal/hooks.test.js | 204 ----- .../SelectSessionModal/hooks.test.jsx | 294 +++++++ .../components/ConfirmPane.jsx | 5 +- .../components/ConfirmPane.test.jsx | 10 + .../components/FinishedPane.jsx | 6 +- .../components/FinishedPane.test.jsx | 10 + .../UnenrollConfirmModal/hooks/index.js | 4 +- .../UnenrollConfirmModal/hooks/index.test.js | 15 +- .../UnenrollConfirmModal/hooks/reasons.js | 18 +- .../hooks/reasons.test.js | 178 ---- .../hooks/reasons.test.jsx | 262 ++++++ .../UnenrollConfirmModal/index.test.jsx | 9 +- src/data/constants/app.test.js | 163 ++++ src/data/context/BackedData.test.tsx | 360 ++++++++ src/data/context/BackedDataProvider.tsx | 65 ++ src/data/context/Filters.test.tsx | 772 ++++++++++++++++ src/data/context/FiltersProvider.tsx | 127 +++ src/data/context/Masquerade.test.tsx | 580 ++++++++++++ src/data/context/MasqueradeProvider.tsx | 65 ++ src/data/context/SelectSession.test.tsx | 664 ++++++++++++++ src/data/context/SelectSessionProvider.tsx | 85 ++ src/data/context/index.test.tsx | 61 ++ src/data/context/index.tsx | 25 + src/data/hooks/index.ts | 19 + src/data/hooks/mutationHooks.test.tsx | 346 ++++++++ src/data/hooks/mutationHooks.ts | 131 +++ src/data/hooks/queryHooks.test.tsx | 140 +++ src/data/hooks/queryHooks.ts | 38 + src/data/hooks/queryKeys.ts | 10 + src/data/redux/app/index.js | 2 - src/data/redux/app/reducer.js | 81 -- src/data/redux/app/reducer.test.js | 124 --- src/data/redux/app/selectors/appSelectors.js | 23 - .../redux/app/selectors/appSelectors.test.js | 28 - src/data/redux/app/selectors/courseCard.js | 155 ---- .../redux/app/selectors/courseCard.test.js | 398 --------- src/data/redux/app/selectors/currentList.js | 58 -- .../redux/app/selectors/currentList.test.js | 185 ---- src/data/redux/app/selectors/index.js | 13 - .../redux/app/selectors/simpleSelectors.js | 38 - .../app/selectors/simpleSelectors.test.js | 75 -- src/data/redux/hooks/app.js | 107 --- src/data/redux/hooks/index.js | 2 - src/data/redux/hooks/requests.js | 45 - src/data/redux/index.js | 37 - src/data/redux/requests/index.js | 2 - src/data/redux/requests/reducer.js | 54 -- src/data/redux/requests/reducer.test.js | 62 -- src/data/redux/requests/selectors.js | 40 - src/data/redux/requests/selectors.test.js | 110 --- src/data/services/lms/api.js | 77 -- src/data/services/lms/api.test.js | 156 ---- src/data/services/lms/api.test.tsx | 288 ++++++ src/data/services/lms/api.ts | 95 ++ src/data/services/lms/fakeData/courses.js | 828 ------------------ src/data/services/lms/fakeData/testUtils.js | 40 - src/data/store.js | 37 - src/data/store.test.js | 68 -- src/data/utils.js | 19 - src/data/utils.test.js | 29 - src/hooks/api.js | 105 --- src/hooks/api.test.js | 275 ------ src/hooks/index.js | 12 +- src/hooks/useCourseData.test.tsx | 330 +++++++ src/hooks/useCourseData.ts | 14 + src/hooks/useCourseTrackingEvent.test.tsx | 389 ++++++++ src/hooks/useCourseTrackingEvent.ts | 14 + src/hooks/useEntitlementInfo.test.tsx | 534 +++++++++++ src/hooks/useEntitlementInfo.ts | 33 + src/hooks/useIsMasquerading.test.tsx | 450 ++++++++++ src/hooks/useIsMasquerading.ts | 10 + src/index.jsx | 25 +- src/index.test.jsx | 1 - .../WidgetSidebarSlot/index.test.jsx | 10 +- .../LookingForChallengeWidget/index.jsx | 5 +- .../LookingForChallengeWidget/index.test.jsx | 14 +- src/setupTest.jsx | 19 - src/test/app.test.jsx | 279 ------ src/test/inspector.js | 50 -- src/test/messages.js | 29 - src/test/utils.js | 3 - src/tracking/trackers/socialShare.js | 4 +- src/utils/StrictDict.js | 1 - src/utils/dataTransformers.test.ts | 629 +++++++++++++ src/utils/dataTransformers.ts | 67 ++ src/utils/hooks.test.tsx | 55 ++ tsconfig.json | 4 + 186 files changed, 9104 insertions(+), 5899 deletions(-) create mode 100644 src/containers/AppWrapper/index.test.tsx delete mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js create mode 100644 src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx delete mode 100644 src/containers/CourseFilterControls/hooks.js delete mode 100644 src/containers/CourseFilterControls/hooks.test.js delete mode 100644 src/containers/CoursesPanel/hooks.js delete mode 100644 src/containers/CoursesPanel/hooks.test.js delete mode 100644 src/containers/EmailSettingsModal/hooks.test.js create mode 100644 src/containers/EmailSettingsModal/hooks.test.jsx delete mode 100644 src/containers/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.js create mode 100644 src/containers/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.jsx delete mode 100644 src/containers/MasqueradeBar/hooks.js delete mode 100644 src/containers/MasqueradeBar/hooks.test.js delete mode 100644 src/containers/RelatedProgramsModal/hooks.js delete mode 100644 src/containers/SelectSessionModal/hooks.test.js create mode 100644 src/containers/SelectSessionModal/hooks.test.jsx delete mode 100644 src/containers/UnenrollConfirmModal/hooks/reasons.test.js create mode 100644 src/containers/UnenrollConfirmModal/hooks/reasons.test.jsx create mode 100644 src/data/context/BackedData.test.tsx create mode 100644 src/data/context/BackedDataProvider.tsx create mode 100644 src/data/context/Filters.test.tsx create mode 100644 src/data/context/FiltersProvider.tsx create mode 100644 src/data/context/Masquerade.test.tsx create mode 100644 src/data/context/MasqueradeProvider.tsx create mode 100644 src/data/context/SelectSession.test.tsx create mode 100644 src/data/context/SelectSessionProvider.tsx create mode 100644 src/data/context/index.test.tsx create mode 100644 src/data/context/index.tsx create mode 100644 src/data/hooks/index.ts create mode 100644 src/data/hooks/mutationHooks.test.tsx create mode 100644 src/data/hooks/mutationHooks.ts create mode 100644 src/data/hooks/queryHooks.test.tsx create mode 100644 src/data/hooks/queryHooks.ts create mode 100644 src/data/hooks/queryKeys.ts delete mode 100644 src/data/redux/app/index.js delete mode 100644 src/data/redux/app/reducer.js delete mode 100644 src/data/redux/app/reducer.test.js delete mode 100644 src/data/redux/app/selectors/appSelectors.js delete mode 100644 src/data/redux/app/selectors/appSelectors.test.js delete mode 100644 src/data/redux/app/selectors/courseCard.js delete mode 100644 src/data/redux/app/selectors/courseCard.test.js delete mode 100644 src/data/redux/app/selectors/currentList.js delete mode 100644 src/data/redux/app/selectors/currentList.test.js delete mode 100644 src/data/redux/app/selectors/index.js delete mode 100644 src/data/redux/app/selectors/simpleSelectors.js delete mode 100644 src/data/redux/app/selectors/simpleSelectors.test.js delete mode 100644 src/data/redux/hooks/app.js delete mode 100644 src/data/redux/hooks/index.js delete mode 100644 src/data/redux/hooks/requests.js delete mode 100644 src/data/redux/index.js delete mode 100644 src/data/redux/requests/index.js delete mode 100644 src/data/redux/requests/reducer.js delete mode 100644 src/data/redux/requests/reducer.test.js delete mode 100644 src/data/redux/requests/selectors.js delete mode 100644 src/data/redux/requests/selectors.test.js delete mode 100644 src/data/services/lms/api.js delete mode 100644 src/data/services/lms/api.test.js create mode 100644 src/data/services/lms/api.test.tsx create mode 100644 src/data/services/lms/api.ts delete mode 100644 src/data/services/lms/fakeData/courses.js delete mode 100644 src/data/services/lms/fakeData/testUtils.js delete mode 100755 src/data/store.js delete mode 100644 src/data/store.test.js delete mode 100644 src/data/utils.js delete mode 100644 src/data/utils.test.js delete mode 100644 src/hooks/api.js delete mode 100644 src/hooks/api.test.js create mode 100644 src/hooks/useCourseData.test.tsx create mode 100644 src/hooks/useCourseData.ts create mode 100644 src/hooks/useCourseTrackingEvent.test.tsx create mode 100644 src/hooks/useCourseTrackingEvent.ts create mode 100644 src/hooks/useEntitlementInfo.test.tsx create mode 100644 src/hooks/useEntitlementInfo.ts create mode 100644 src/hooks/useIsMasquerading.test.tsx create mode 100644 src/hooks/useIsMasquerading.ts delete mode 100644 src/test/app.test.jsx delete mode 100644 src/test/inspector.js delete mode 100644 src/test/messages.js delete mode 100644 src/test/utils.js create mode 100644 src/utils/dataTransformers.test.ts create mode 100644 src/utils/dataTransformers.ts create mode 100644 src/utils/hooks.test.tsx diff --git a/.env b/.env index b9b3085..435e8c3 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ NODE_ENV='production' +APP_ID='learner-dashboard' NODE_PATH=./src BASE_URL='' LMS_BASE_URL='' diff --git a/.env.development b/.env.development index 73183cc..ea1ca4c 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,5 @@ NODE_ENV='development' +APP_ID='learner-dashboard' PORT=1996 BASE_URL='localhost:1996' LMS_BASE_URL='http://localhost:18000' diff --git a/.env.test b/.env.test index aa19975..4dec30e 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,5 @@ NODE_ENV='test' +APP_ID='learner-dashboard' PORT=1996 BASE_URL='localhost:1996' LMS_BASE_URL='http://localhost:18000' diff --git a/example.env.config.js b/example.env.config.js index 70436fd..96fa650 100644 --- a/example.env.config.js +++ b/example.env.config.js @@ -19,6 +19,7 @@ frontend-platform's getConfig loads configuration in the following sequence: module.exports = { NODE_ENV: 'development', + APP_ID: 'learner-dashboard', NODE_PATH: './src', PORT: 1996, BASE_URL: 'localhost:1996', diff --git a/package-lock.json b/package-lock.json index 4cd0b6d..35f9ff2 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.48.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": "^5.2.2", - "redux": "4.2.1", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", "regenerator-runtime": "^0.14.0", - "reselect": "^4.0.0", "util": "^0.12.4" }, "devDependencies": { @@ -58,8 +52,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": { @@ -2651,9 +2644,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "license": "MIT", "optional": true, "dependencies": { @@ -5576,66 +5569,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" - }, - "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", @@ -5700,18 +5633,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "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", @@ -5983,6 +5904,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.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6560,6 +6507,7 @@ "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -9915,13 +9863,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==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -13520,22 +13461,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", - "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", @@ -16091,13 +16016,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", @@ -19199,6 +19117,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -19538,37 +19457,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", @@ -19735,12 +19623,6 @@ "devOptional": true, "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.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", diff --git a/package.json b/package.json index 27d3a2a..c70e82e 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.48.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": "^5.2.2", - "redux": "4.2.1", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", "regenerator-runtime": "^0.14.0", - "reselect": "^4.0.0", "util": "^0.12.4" }, "devDependencies": { @@ -78,7 +72,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 2c148f9..c8d914f 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 102d379..900e96d 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', () => ({ + 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/config/index.js b/src/config/index.js index ae923c2..5c23538 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,5 +1,6 @@ const configuration = { // BASE_URL: process.env.BASE_URL, + APP_ID: process.env.APP_ID, LMS_BASE_URL: process.env.LMS_BASE_URL, ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL, CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL, diff --git a/src/containers/AppWrapper/index.test.tsx b/src/containers/AppWrapper/index.test.tsx new file mode 100644 index 0000000..00e8829 --- /dev/null +++ b/src/containers/AppWrapper/index.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 559b1be..3208817 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 8b5ea02..aedd64b 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 03c3a2d..3d5c344 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 5728b2f..b6db87f 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 5762a1a..2e27ba1 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 ba9e21c..8fc9d7d 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 ef74b0d..87e7335 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 a9b2f65..d5cbc23 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 5f4a34f..4806262 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 1cbacc3..4d8948b 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 556628d..d8b3759 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 4575a8a..993ba99 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 db26b1c..0e3f9f6 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 { - isVerified, - isAuditAccessExpired, - coursewareAccess = {}, - } = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + const { + isVerified = false, + isAuditAccessExpired = false, + coursewareAccess = {}, + } = 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 42d4033..587d5e8 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 89e92eb..dc9ebb3 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 729de8f..92d6341 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('hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), })); + +jest.mock('hooks', () => ({ + 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 bb33419..af60085 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 b38fdec..585df6e 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 dfb7d41..6b2d18d 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 082e914..7ee5b57 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 b7b44dc..f45ec56 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 afd66e7..5869198 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 03df3ba..eba071a 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 74b20e9..f484d2b 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 81cffe9..e86908a 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: (response) => { + setRequestData(response.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 deleted file mode 100644 index d3e5c06..0000000 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { MockUseState } from 'testUtils'; -import { apiHooks } from 'hooks'; -import * as hooks from './hooks'; - -jest.mock('hooks', () => ({ - apiHooks: { - useCreateCreditRequest: jest.fn(), - }, -})); - -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); -const event = { preventDefault: jest.fn() }; - -let out; -describe('Credit Banner view hooks', () => { - describe('state', () => { - state.testGetter(state.keys.creditRequestData); - }); - describe('useCreditRequestData', () => { - beforeEach(() => { - state.mock(); - out = hooks.useCreditRequestData(cardId); - }); - describe('behavior', () => { - it('initializes creditRequestData state field with null value', () => { - state.expectInitializedWith(state.keys.creditRequestData, null); - }); - it('calls useCreateCreditRequest with passed cardID', () => { - expect(apiHooks.useCreateCreditRequest).toHaveBeenCalledWith(cardId); - }); - }); - describe('output', () => { - it('returns requestData state value', () => { - state.mockVal(state.keys.creditRequestData, requestData); - out = hooks.useCreditRequestData(cardId); - expect(out.requestData).toEqual(requestData); - }); - describe('createCreditRequest', () => { - it('returns an event handler that prevents default click behavior', () => { - out.createCreditRequest(event); - expect(event.preventDefault).toHaveBeenCalled(); - }); - 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); - }); - }); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx new file mode 100644 index 0000000..d405a28 --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx @@ -0,0 +1,192 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import * as api from 'data/services/lms/api'; +import { useCourseData } from 'hooks'; +import { AppContext } from '@edx/frontend-platform/react'; +import * as hooks from './hooks'; + +jest.mock('data/services/lms/api', () => ({ + createCreditRequest: jest.fn(), +})); + +jest.mock('hooks', () => ({ + useCourseData: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + ...jest.requireActual('@edx/frontend-platform/logging'), + logError: jest.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + return wrapper; +}; + +describe('useCreditRequestData', () => { + let wrapper; + + beforeEach(() => { + wrapper = createWrapper(); + (useCourseData as jest.Mock).mockReturnValue({ + credit: { providerId: 'provider-123' }, + courseRun: { courseId: 'course-456' }, + }); + jest.clearAllMocks(); + }); + + it('initializes requestData as null', () => { + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + expect(result.current.requestData).toBeNull(); + }); + + it('returns createCreditRequest function', () => { + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + expect(typeof result.current.createCreditRequest).toBe('function'); + }); + + it('prevents default event behavior', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('calls API with correct parameters', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: 'course-456', + username: 'test-user', + }); + }); + + it('sets requestData with response data on success', async () => { + const event = { preventDefault: jest.fn() }; + const responseData = { data: { id: 'credit-123', status: 'pending' } }; + (api.createCreditRequest as jest.Mock).mockResolvedValue(responseData); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: 'course-456', + username: 'test-user', + }); + + await waitFor(() => { + expect(result.current.requestData).toEqual(responseData.data); + }); + }); + + it('handles missing providerId gracefully', async () => { + const event = { preventDefault: jest.fn() }; + (useCourseData as jest.Mock).mockReturnValue({ + credit: null, + courseRun: { courseId: 'course-456' }, + }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: undefined, + courseId: 'course-456', + username: 'test-user', + }); + }); + + it('handles missing courseId gracefully', async () => { + const event = { preventDefault: jest.fn() }; + (useCourseData as jest.Mock).mockReturnValue({ + credit: { providerId: 'provider-123' }, + courseRun: null, + }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: undefined, + username: 'test-user', + }); + }); + + it('handles API errors without crashing', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(result.current.requestData).toBeNull(); + }); + + it('uses cardId to fetch course data', () => { + renderHook(() => hooks.useCreditRequestData('different-card'), { wrapper }); + + expect(useCourseData).toHaveBeenCalledWith('different-card'); + }); + + it('handles undefined response data', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ status: 200 }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + await waitFor(() => { + expect(result.current.requestData).toBeUndefined(); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx index 1675184..abd0aa3 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 898bef3..d838df9 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 a2400b1..0453e1f 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 1605820..e99f2da 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 ef05f1d..158f0b8 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 90f273b..a0d10ce 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 bcf285a..719d909 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 7d66991..d64d5e3 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js @@ -1,23 +1,26 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { keyStore } from 'utils'; -import { utilHooks, reduxHooks } from 'hooks'; - +import { utilHooks, useCourseData } from 'hooks'; +import { useSelectSessionModal } from 'data/context'; import * as hooks from './hooks'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +const updateSelectSessionModalMock = jest.fn().mockName('updateSelectSessionModal'); +jest.mock('data/context', () => ({ + useSelectSessionModal: 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 +63,13 @@ describe('CourseCardDetails hooks', () => { const runHook = ({ provider = {}, entitlement = {} }) => { jest.spyOn(hooks, hookKeys.useAccessMessage) .mockImplementationOnce(mockAccessMessage); - reduxHooks.useCardProviderData.mockReturnValueOnce({ - ...providerData, - ...provider, + useCourseData.mockReturnValue({ + courseProvider: { ...providerData, ...provider }, + course: { courseNumber }, + courseRun: {}, + entitlement: { ...entitlementData, ...entitlement }, }); - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ - ...entitlementData, - ...entitlement, - }); - reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber }); + useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock }); out = hooks.useCardDetailsData({ cardId }); }; beforeEach(() => { @@ -85,6 +86,10 @@ describe('CourseCardDetails hooks', () => { it('forward changeOrLeaveSessionMessage', () => { expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton)); }); + it('calls updateSelectSessionModal when openSessionModal is called', () => { + out.openSessionModal(); + expect(updateSelectSessionModalMock).toHaveBeenCalledWith(cardId); + }); }); describe('useAccessMessage', () => { @@ -101,21 +106,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 97d22a7..7bf9e26 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 dc86aed..ef4a5c1 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 7c89421..16bd626 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 8b28973..3de1f49 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 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 b423cc3..49b9f38 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 }, + const mockHooks = (returnVals = {}) => { + useInitializeLearnerHome.mockReturnValue({ + data: { + socialShareSettings: { + 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, + 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 918ef37..7f202bb 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 9d07bff..4db206d 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 5d7bc7c..80fec1c 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 6d62451..d8869e0 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 2b03dac..3c122f8 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 eaa8d6b..babae7f 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 9d80c0a..15c1cd3 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 50d2ccc..08bb009 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 9a0f5ca..76973d5 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 86f89b6..9010873 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 a121c74..e25f578 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 aded208..c499515 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 4dd9818..2fde57a 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -1,7 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; - +import track from 'tracking'; import { Button, Form, @@ -14,44 +13,51 @@ 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 = () => { + track.filter.filterClicked(); + setIsOpen(true); + }; + const closeFiltersOptions = () => { + track.filter.filterOptionSelected(filters); + 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 (