From 424422cafa56f4c3d1317a5470563cbb662c933d Mon Sep 17 00:00:00 2001 From: Muhammad Noyan Aziz Date: Mon, 30 Jun 2025 19:35:32 +0500 Subject: [PATCH 01/26] feat: added a generic creditPurchase Url logic (#675) Co-authored-by: Adolfo R. Brandes --- src/data/services/lms/urls.js | 9 ++++++--- src/data/services/lms/urls.test.js | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index 457c76a33..055d3e759 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -3,8 +3,6 @@ import { StrictDict } from '../../../utils'; import { getAppConfig, getSiteConfig } from '@openedx/frontend-base'; -export const getEcommerceUrl = () => getAppConfig(appId).ECOMMERCE_BASE_URL; - const getBaseUrl = () => getSiteConfig().lmsBaseUrl; export const getApiUrl = () => (`${getSiteConfig().lmsBaseUrl}/api`); @@ -25,7 +23,12 @@ export const learningMfeUrl = (url) => updateUrl(getAppConfig(appId).LEARNING_BA // static view url const programsUrl = () => baseAppUrl('/dashboard/programs'); -export const creditPurchaseUrl = (courseId) => `${getEcommerceUrl()}/credit/checkout/${courseId}/`; +export const creditPurchaseUrl = (courseId) => { + const config = getAppConfig(appId); + return config.CREDIT_PURCHASE_URL + ? `${config.CREDIT_PURCHASE_URL}/${courseId}/` + : `${config.ECOMMERCE_BASE_URL}/credit/checkout/${courseId}/`; +}; export const creditRequestUrl = (providerId) => `${getApiUrl()}/credit/v1/providers/${providerId}/request/`; export default StrictDict({ diff --git a/src/data/services/lms/urls.test.js b/src/data/services/lms/urls.test.js index a0684582a..60aa00b53 100644 --- a/src/data/services/lms/urls.test.js +++ b/src/data/services/lms/urls.test.js @@ -39,6 +39,13 @@ describe('urls', () => { const url = urls.creditPurchaseUrl(courseId); expect(url).toEqual(expect.stringContaining(courseId)); }); + it('returns CREDIT_PURCHASE_URL if set, with courseId', () => { + const courseId = 'test-course-id'; + const config = getAppConfig(appId); + config.CREDIT_PURCHASE_URL = 'http://credit-purchase.example.com'; + const url = urls.creditPurchaseUrl(courseId); + expect(url).toBe(`http://credit-purchase.example.com/${courseId}/`); + }); }); describe('creditRequestUrl', () => { it('builds from api url and loads providerId', () => { From 439bcbdc47e6a5f72c4d8f3bb220946ff6f3dd60 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Wed, 3 Sep 2025 23:39:49 +0500 Subject: [PATCH 02/26] feat: improve unenrollment process (#704) Co-authored-by: Deborah Kaplan Co-authored-by: Adolfo R. Brandes --- .../components/ConfirmPane.jsx | 6 +++++ .../components/ConfirmPane.test.jsx | 1 + .../components/FinishedPane.jsx | 11 +++++---- .../components/FinishedPane.test.jsx | 20 +++------------- .../components/ReasonPane.jsx | 11 +++++---- .../components/ReasonPane.test.jsx | 6 ++--- .../components/messages.js | 24 +++++++++---------- .../UnenrollConfirmModal/constants.js | 8 ++++++- .../UnenrollConfirmModal/hooks/index.js | 2 +- .../UnenrollConfirmModal/hooks/reasons.js | 16 ++++--------- .../hooks/reasons.test.js | 18 ++------------ src/containers/UnenrollConfirmModal/index.jsx | 6 ++--- .../UnenrollConfirmModal/index.test.jsx | 15 +++++------- 13 files changed, 62 insertions(+), 82 deletions(-) diff --git a/src/containers/UnenrollConfirmModal/components/ConfirmPane.jsx b/src/containers/UnenrollConfirmModal/components/ConfirmPane.jsx index 884d57f81..c46d70c7a 100644 --- a/src/containers/UnenrollConfirmModal/components/ConfirmPane.jsx +++ b/src/containers/UnenrollConfirmModal/components/ConfirmPane.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { reduxHooks } from 'hooks'; import { useIntl } from '@openedx/frontend-base'; import { @@ -10,13 +11,17 @@ import { import messages from './messages'; export const ConfirmPane = ({ + cardId, handleClose, handleConfirm, }) => { const { formatMessage } = useIntl(); + const { courseName } = reduxHooks.useCardCourseData(cardId); + const courseTitle = “{courseName}”; return ( <>

{formatMessage(messages.confirmHeader)}

+

{formatMessage(messages.confirmText, { courseTitle })}

@@ -29,7 +32,7 @@ export const FinishedPane = ({ }; FinishedPane.propTypes = { handleClose: PropTypes.func.isRequired, - gaveReason: PropTypes.bool.isRequired, + cardId: PropTypes.string.isRequired, }; export default FinishedPane; diff --git a/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx b/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx index 977040425..a3e2b0cca 100644 --- a/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx +++ b/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx @@ -6,7 +6,7 @@ import { FinishedPane } from './FinishedPane'; import messages from './messages'; const props = { - gaveReason: true, + cardId: 'cardId', handleClose: jest.fn().mockName('props.handleClose'), }; @@ -25,22 +25,8 @@ describe('UnenrollConfirmModal FinishedPane', () => { expect(returnButton).toBeInTheDocument(); }); it('Gave reason, display thanks message', () => { - const thanksMsg = screen.getByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeInTheDocument(); - expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText)); - }); - }); - describe('Did not give reason', () => { - it('Does not display thanks message', () => { - const customProps = { - gaveReason: false, - handleClose: jest.fn().mockName('props.handleClose'), - }; - render(); - const thanksMsg = screen.queryByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeNull(); - const finishMsg = screen.getByText(formatMessage(messages.finishText)); - expect(finishMsg).toBeInTheDocument(); + const finishSuccessMessage = screen.getByText((text) => text.includes('Unenrollment Successful')); + expect(finishSuccessMessage).toBeInTheDocument(); }); }); }); diff --git a/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx b/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx index deda9d081..d6db82b1a 100644 --- a/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx +++ b/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx @@ -13,6 +13,7 @@ import messages from './messages'; export const ReasonPane = ({ reason, + handleClose, }) => { const { formatMessage } = useIntl(); const option = (key) => ( @@ -27,6 +28,7 @@ export const ReasonPane = ({ name="unenrollReason" onChange={reason.selectOption} value={reason.selected} + defaultValue={constants.reasonKeys.preferNotToSay} > {constants.order.map(option)} @@ -35,12 +37,13 @@ export const ReasonPane = ({ placeholder={formatMessage(constants.messages.customPlaceholder)} /> + {option(constants.reasonKeys.preferNotToSay)} - - @@ -50,7 +53,6 @@ export const ReasonPane = ({ ReasonPane.propTypes = { reason: PropTypes.shape({ value: PropTypes.string, - handleSkip: PropTypes.func, hasReason: PropTypes.bool, selectOption: PropTypes.func, customOption: PropTypes.shape({ @@ -60,6 +62,7 @@ ReasonPane.propTypes = { selected: PropTypes.string, handleSubmit: PropTypes.func.isRequired, }).isRequired, + handleClose: PropTypes.func.isRequired, }; export default ReasonPane; diff --git a/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx b/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx index f7ff6ee5d..2cd92f0ba 100644 --- a/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx +++ b/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx @@ -28,11 +28,11 @@ describe('UnenrollConfirmModal ReasonPane', () => { render(); const radioButtons = screen.getAllByRole('radio'); expect(radioButtons).toBeDefined(); - expect(radioButtons.length).toBe(10); + expect(radioButtons.length).toBe(11); }); - it('render skip button', () => { + it('render cancel button', () => { render(); - const skipButton = screen.getByRole('button', { name: formatMessage(messages.reasonSkip) }); + const skipButton = screen.getByRole('button', { name: formatMessage(messages.confirmCancel) }); expect(skipButton).toBeInTheDocument(); }); it('render submit button', () => { diff --git a/src/containers/UnenrollConfirmModal/components/messages.js b/src/containers/UnenrollConfirmModal/components/messages.js index c74bf2475..426b5d8af 100644 --- a/src/containers/UnenrollConfirmModal/components/messages.js +++ b/src/containers/UnenrollConfirmModal/components/messages.js @@ -4,12 +4,17 @@ const messages = defineMessages({ confirmHeader: { id: 'learner-dash.unenrollConfirm.confirm.header', description: 'Header for confirm unenroll modal', - defaultMessage: 'Unenroll from course?', + defaultMessage: 'Confirm Unenrollment', + }, + confirmText: { + id: 'learner-dash.unenrollConfirm.confirm.text', + description: 'Text for confirm unenroll modal', + defaultMessage: 'Are you sure you want to unenroll from the course {courseTitle} ?', }, confirmCancel: { id: 'learner-dash.unenrollConfirm.confirm.cancel', description: 'Cancel action for confirm unenroll modal', - defaultMessage: 'Never mind', + defaultMessage: 'Cancel', }, confirmUnenroll: { id: 'learner-dash.unenrollConfirm.confirm.unenroll', @@ -19,7 +24,7 @@ const messages = defineMessages({ reasonHeading: { id: 'learner-dash.unenrollConfirm.confirm.reason.heading', description: 'Heading for unenroll reason modal', - defaultMessage: `What's your main reason for unenrolling?`, + defaultMessage: 'Why are you unenrolling?', }, reasonSkip: { id: 'learner-dash.unenrollConfirm.confirm.reason.skip', @@ -29,27 +34,22 @@ const messages = defineMessages({ reasonSubmit: { id: 'learner-dash.unenrollConfirm.confirm.reason.submit', description: 'Submit action for unenroll reason modal', - defaultMessage: 'Submit reason', + defaultMessage: 'Unenroll', }, finishHeading: { id: 'learner-dash.unenrollConfirm.confirm.finish.heading', description: 'Heading for unenroll finish modal', - defaultMessage: 'You are unenrolled', - }, - finishThanksText: { - id: 'learner-dash.unenrollConfirm.confirm.finish.thanks-text', - description: 'Thank you message on unenroll modal for providing a reason', - defaultMessage: 'Thank you for sharing your reason for unenrolling. ', + defaultMessage: 'Unenrollment Successful', }, finishText: { id: 'learner-dash.unenrollConfirm.confirm.finish.text', description: 'Text for unenroll finish modal', - defaultMessage: 'This course will be removed from your dashboard.', + defaultMessage: 'You have been unenrolled from the course {courseTitle}', }, finishReturn: { id: 'learner-dash.unenrollConfirm.confirm.finish.return', description: 'Return action for unenroll finish modal', - defaultMessage: 'Return to dashboard', + defaultMessage: 'Ok', }, }); diff --git a/src/containers/UnenrollConfirmModal/constants.js b/src/containers/UnenrollConfirmModal/constants.js index 868f751e2..49e5eec77 100644 --- a/src/containers/UnenrollConfirmModal/constants.js +++ b/src/containers/UnenrollConfirmModal/constants.js @@ -12,18 +12,19 @@ export const reasonKeys = StrictDict({ quality: 'quality', easy: 'easy', custom: 'custom', + preferNotToSay: 'prefer-not-to-say', }); export const order = [ reasonKeys.prereqs, reasonKeys.difficulty, + reasonKeys.easy, reasonKeys.goals, reasonKeys.broken, reasonKeys.time, reasonKeys.browse, reasonKeys.support, reasonKeys.quality, - reasonKeys.easy, ]; const messages = defineMessages({ @@ -77,6 +78,11 @@ const messages = defineMessages({ description: 'Unenroll custom reason option placeholder text', defaultMessage: 'Other', }, + [reasonKeys.preferNotToSay]: { + id: 'learner-dash.unenrollConfirm.reasons.prefer-not-to-say', + description: 'Unenroll reason option - prefer not to say', + defaultMessage: 'I prefer not to say', + }, }); export default { diff --git a/src/containers/UnenrollConfirmModal/hooks/index.js b/src/containers/UnenrollConfirmModal/hooks/index.js index 85c533421..2fefa019e 100644 --- a/src/containers/UnenrollConfirmModal/hooks/index.js +++ b/src/containers/UnenrollConfirmModal/hooks/index.js @@ -26,7 +26,7 @@ export const useUnenrollData = ({ closeModal, cardId }) => { let modalState; if (isConfirmed) { - modalState = (reason.isSubmitted || reason.isSkipped) + modalState = (reason.isSubmitted) ? modalStates.finished : modalStates.reason; } else { modalState = modalStates.confirm; diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.js b/src/containers/UnenrollConfirmModal/hooks/reasons.js index a288496fc..28103b29a 100644 --- a/src/containers/UnenrollConfirmModal/hooks/reasons.js +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.js @@ -9,10 +9,10 @@ import { StrictDict } from '../../../utils'; import track from '../../../tracking'; import * as module from './reasons'; +import constants from '../constants'; export const state = StrictDict({ customOption: (val) => React.useState(val), // eslint-disable-line - isSkipped: (val) => React.useState(val), // eslint-disable-line selectedReason: (val) => React.useState(val), // eslint-disable-line isSubmitted: (val) => React.useState(val), //eslint-disable-line }); @@ -21,12 +21,12 @@ export const useUnenrollReasons = ({ cardId, }) => { // The selected option element from the menu - const [selectedReason, setSelectedReason] = module.state.selectedReason(null); + const [selectedReason, setSelectedReason] = module.state.selectedReason( + constants.reasonKeys.preferNotToSay, + ); // Custom option element entry value const [customOption, setCustomOption] = module.state.customOption(''); - // Did the user choose to skip selecting a reason? - const [isSkipped, setIsSkipped] = module.state.isSkipped(false); // Did the user submit an unenrollment reason const [isSubmitted, setIsSubmitted] = module.state.isSubmitted(false); @@ -47,15 +47,9 @@ export const useUnenrollReasons = ({ const handleClear = () => { setSelectedReason(null); setCustomOption(''); - setIsSkipped(false); setIsSubmitted(false); }; - const handleSkip = () => { - setIsSkipped(true); - unenrollFromCourse(); - }; - const handleSubmit = (e) => { handleTrackReasons(e); setIsSubmitted(true); @@ -68,10 +62,8 @@ export const useUnenrollReasons = ({ return { customOption: { value: customOption, onChange: handleCustomOptionChange }, handleClear, - handleSkip, handleSubmit, hasReason, - isSkipped, isSubmitted, selectOption: handleSelectOption, submittedReason, diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.test.js b/src/containers/UnenrollConfirmModal/hooks/reasons.test.js index 6b0e0fb71..6590f1023 100644 --- a/src/containers/UnenrollConfirmModal/hooks/reasons.test.js +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.test.js @@ -7,6 +7,7 @@ import { } from '@src/hooks'; import * as hooks from './reasons'; +import constants from '../constants'; jest.mock('@src/hooks', () => ({ apiHooks: { @@ -39,7 +40,6 @@ const loadHook = (isEntitlement = false) => { describe('UnenrollConfirmModal reasons hooks', () => { describe('state fields', () => { state.testGetter(state.keys.customOption); - state.testGetter(state.keys.isSkipped); state.testGetter(state.keys.isSubmitted); state.testGetter(state.keys.selectedReason); }); @@ -55,14 +55,11 @@ describe('UnenrollConfirmModal reasons hooks', () => { describe('behavior', () => { describe('state fields', () => { it('initializes selectedReason with null', () => { - state.expectInitializedWith(state.keys.selectedReason, null); + state.expectInitializedWith(state.keys.selectedReason, constants.reasonKeys.preferNotToSay); }); it('initializes customOption with empty string', () => { state.expectInitializedWith(state.keys.customOption, ''); }); - it('initializes isSkipped with false', () => { - state.expectInitializedWith(state.keys.isSkipped, false); - }); it('initializes isSubmitted with false', () => { state.expectInitializedWith(state.keys.isSubmitted, false); }); @@ -140,15 +137,9 @@ describe('UnenrollConfirmModal reasons hooks', () => { out.handleClear(); expect(state.setState.selectedReason).toHaveBeenCalledWith(null); expect(state.setState.customOption).toHaveBeenCalledWith(''); - expect(state.setState.isSkipped).toHaveBeenCalledWith(false); expect(state.setState.isSubmitted).toHaveBeenCalledWith(false); }); }); - test('handleSkip sets isSkipped and isSubmitted, and unenrolls w/out a reason', () => { - out.handleSkip(); - expect(state.setState.isSkipped).toHaveBeenCalledWith(true); - expect(unenrollFromCourse).toHaveBeenCalledWith(); - }); describe('handleSubmit', () => { it('tracks reason event and calls unenroll action', () => { state.mockVal(state.keys.selectedReason, testValue); @@ -160,11 +151,6 @@ describe('UnenrollConfirmModal reasons hooks', () => { expect(unenrollFromCourse).toHaveBeenCalledWith(); }); }); - test('isSkipped returns state value', () => { - state.mockVal(state.keys.isSkipped, testValue); - loadHook(); - expect(out.isSkipped).toEqual(testValue); - }); test('isSubmitted returns state value', () => { state.mockVal(state.keys.isSubmitted, testValue); loadHook(); diff --git a/src/containers/UnenrollConfirmModal/index.jsx b/src/containers/UnenrollConfirmModal/index.jsx index 96eba201e..ce4231dd7 100644 --- a/src/containers/UnenrollConfirmModal/index.jsx +++ b/src/containers/UnenrollConfirmModal/index.jsx @@ -38,13 +38,13 @@ export const UnenrollConfirmModal = ({ style={{ textAlign: 'start' }} > {(modalState === modalStates.confirm) && ( - + )} {(modalState === modalStates.finished) && ( - + )} {(modalState === modalStates.reason) && ( - + )} diff --git a/src/containers/UnenrollConfirmModal/index.test.jsx b/src/containers/UnenrollConfirmModal/index.test.jsx index b31e7f69a..a1bbdae99 100644 --- a/src/containers/UnenrollConfirmModal/index.test.jsx +++ b/src/containers/UnenrollConfirmModal/index.test.jsx @@ -17,7 +17,6 @@ describe('UnenrollConfirmModal component', () => { const hookProps = { confirm: jest.fn().mockName('hooks.confirm'), reason: { - isSkipped: false, reasonProps: 'other', }, close: jest.fn().mockName('hooks.close'), @@ -49,22 +48,20 @@ describe('UnenrollConfirmModal component', () => { render(); const finishHeading = screen.getByText(formatMessage(messages.finishHeading)); expect(finishHeading).toBeInTheDocument(); - const thanksMsg = screen.getByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeInTheDocument(); - expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText)); + const finishMsg = screen.getByText((text) => text.includes('You have been unenrolled from the course')); + expect(finishMsg).toBeInTheDocument(); }); - it('modalStates.finished, reason skipped', () => { + it('modalStates.finished, cancel unenrollment', () => { hooks.useUnenrollData.mockReturnValueOnce({ ...hookProps, modalState: hooks.modalStates.finished, - reason: { isSkipped: true }, }); render(); const finishHeading = screen.getByText(formatMessage(messages.finishHeading)); expect(finishHeading).toBeInTheDocument(); - const thanksMsg = screen.queryByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeNull(); - const finishMsg = screen.getByText(formatMessage(messages.finishText)); + const okButton = screen.queryByText((text) => text.includes('Ok')); + expect(okButton).toBeInTheDocument(); + const finishMsg = screen.queryByText('You have been unenrolled from the course'); expect(finishMsg).toBeInTheDocument(); }); it('modalStates.reason, should display correct component with no shadow', () => { From 67eb0b2374d8114b96e8390b42485e36feb787ac Mon Sep 17 00:00:00 2001 From: Ejaz Ahmad <86868918+jajjibhai008@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:31:14 +0500 Subject: [PATCH 03/26] feat: added the ability for instances to use local translations from extra repositories (#752) Co-authored-by: Adolfo R. Brandes --- Makefile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2fc1ab8b3..47ea1224d 100755 --- a/Makefile +++ b/Makefile @@ -12,6 +12,11 @@ transifex_temp = ./temp/babel-plugin-formatjs NPM_TESTS=build i18n_extract lint test +# Variables for additional translation sources and imports (define in edx-internal if needed) +ATLAS_EXTRA_SOURCES ?= +ATLAS_EXTRA_INTL_IMPORTS ?= +ATLAS_OPTIONS ?= + .PHONY: test test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite @@ -60,9 +65,10 @@ pull_translations: && atlas pull $(ATLAS_OPTIONS) \ translations/frontend-base/src/i18n/messages:frontend-base \ translations/paragon/src/i18n/messages:paragon \ - translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard + translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard \ + $(ATLAS_EXTRA_SOURCES) - $(intl_imports) frontend-base paragon frontend-app-learner-dashboard + $(intl_imports) frontend-base paragon frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS) # This target is used by CI. validate-no-uncommitted-package-lock-changes: From 2473745b2dff3b85cdb1281bda6776cf746c3dd7 Mon Sep 17 00:00:00 2001 From: Maxwell Frank <92897870+MaxFrank13@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:29:57 -0300 Subject: [PATCH 04/26] fix(deps): remove filesize dependency (#767) Co-authored-by: Adolfo R. Brandes --- package-lock.json | 16 +++------------- package.json | 1 - 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index dd98c6085..261bd0ede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@redux-devtools/extension": "3.3.0", "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", - "filesize": "^10.0.0", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", @@ -5735,6 +5734,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", @@ -9862,7 +9862,6 @@ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", "license": "MIT", - "peer": true, "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -9908,15 +9907,6 @@ "node": ">= 12" } }, - "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 10.4.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -17537,7 +17527,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17558,7 +17547,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.3.tgz", "integrity": "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -20456,6 +20444,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -20584,6 +20573,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", diff --git a/package.json b/package.json index e552788ff..623b62691 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@redux-devtools/extension": "3.3.0", "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", - "filesize": "^10.0.0", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", From f8fee45520d91a125840aeb933bab2e0ae7f56ac Mon Sep 17 00:00:00 2001 From: Maxwell Frank <92897870+MaxFrank13@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:32:58 -0300 Subject: [PATCH 05/26] fix: update react-share to v5 (#795) Co-authored-by: Adolfo R. Brandes --- package-lock.json | 14 +++++--------- package.json | 2 +- .../components/CourseCardMenu/SocialShareMenu.jsx | 2 ++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 261bd0ede..cdf67427d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^4.4.0", + "react-share": "^5.2.2", "redux-logger": "3.0.6", "redux-thunk": "2.4.2", "reselect": "^4.0.0" @@ -16834,20 +16834,16 @@ } }, "node_modules/react-share": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/react-share/-/react-share-4.4.1.tgz", - "integrity": "sha512-AJ9m9RiJssqvYg7MoJUc9J0D7b/liWrsfQ99ndKc5vJ4oVHHd4Fy87jBlKEQPibT40oYA3AQ/a9/oQY6/yaigw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.2.2.tgz", + "integrity": "sha512-z0nbOX6X6vHHWAvXduNkYeJUKTKNpKM5Xpmc5a2BxjJhUWl+sE7AsSEMmYEUj2DuDjZr5m7KFIGF0sQPKcUN6w==", "license": "MIT", "dependencies": { "classnames": "^2.3.2", "jsonp": "^0.2.1" }, - "engines": { - "node": ">=6.9.0", - "npm": ">=5.0.0" - }, "peerDependencies": { - "react": "^16.3.0 || ^17 || ^18" + "react": "^17 || ^18 || ^19" } }, "node_modules/react-style-singleton": { diff --git a/package.json b/package.json index 623b62691..28752f4ec 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^4.4.0", + "react-share": "^5.2.2", "redux-logger": "3.0.6", "redux-thunk": "2.4.2", "reselect": "^4.0.0" diff --git a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx index 5a18029d6..f0b616e5a 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx @@ -51,6 +51,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => { })} resetButtonStyle={false} className="pgn__dropdown-item dropdown-item" + aria-label="facebook" > {formatMessage(messages.shareToFacebook)} @@ -65,6 +66,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => { })} resetButtonStyle={false} className="pgn__dropdown-item dropdown-item" + aria-label="twitter" > {formatMessage(messages.shareToTwitter)} From c1806e0bdf9470f18d4e4b29c8f0a2a8be604183 Mon Sep 17 00:00:00 2001 From: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:34:56 -0300 Subject: [PATCH 06/26] fix(docs): use correct image for custom course banner (#796) Co-authored-by: Adolfo R. Brandes --- src/slots/CourseBannerSlot/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slots/CourseBannerSlot/README.md b/src/slots/CourseBannerSlot/README.md index 87da60a19..e38a57074 100644 --- a/src/slots/CourseBannerSlot/README.md +++ b/src/slots/CourseBannerSlot/README.md @@ -17,7 +17,7 @@ The default CourseBanner looks like this when audit access has expired for the c The following configuration will render a custom implementation of a CourseBanner under every `CourseCard`. -![Screenshot of custom banner added under CourseCard](./images/course_banner_slot_default.png) +![Screenshot of custom banner added under CourseCard](./images/custom_course_banner.png) ```js import { WidgetOperationTypes } from '@openedx/frontend-base'; From df355049a76eccbf357ca84d2c5c4c4705d6f6ad Mon Sep 17 00:00:00 2001 From: "Adolfo R. Brandes" Date: Wed, 4 Mar 2026 19:53:03 -0300 Subject: [PATCH 07/26] fix: break circular dependency in site.config.test.tsx Use 'test' string literal instead of EnvironmentTypes.TEST and import only the type to avoid circular dependency when mocking @openedx/frontend-base itself. Co-Authored-By: Claude Opus 4.6 --- site.config.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site.config.test.tsx b/site.config.test.tsx index 6f9da9078..99991595a 100644 --- a/site.config.test.tsx +++ b/site.config.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base'; +import type { SiteConfig } from '@openedx/frontend-base'; import { appId } from './src/constants'; @@ -10,7 +10,9 @@ const siteConfig: SiteConfig = { loginUrl: 'http://localhost:8000/login', logoutUrl: 'http://localhost:8000/logout', - environment: EnvironmentTypes.TEST, + // Use 'test' instead of EnvironmentTypes.TEST to break a circular dependency + // when mocking `@openedx/frontend-base` itself. + environment: 'test' as SiteConfig['environment'], apps: [{ appId, config: { From 3d1ec2bdd1cb53976b20edfcf325c3435a853268 Mon Sep 17 00:00:00 2001 From: "Adolfo R. Brandes" Date: Wed, 4 Mar 2026 16:43:32 -0300 Subject: [PATCH 08/26] refactor: migrate from Redux to React Query Remove all Redux files and packages. Add React Query hooks, Context providers, typed API layer, data transformers, and tests. Migrate all components including CourseCard, CourseCardBanners, CourseFilterControls, CoursesPanel, Dashboard, modals, and remaining widgets. Reconcile masquerade implementations with frontend-base providers.ts architecture. Co-Authored-By: Jacobo Dominguez Co-Authored-By: Claude --- jest.config.js | 11 +- package-lock.json | 259 +----- package.json | 13 +- src/Main.jsx | 7 +- .../CourseCardActions/BeginCourseButton.jsx | 21 +- .../BeginCourseButton.test.jsx | 48 +- .../CourseCardActions/ResumeButton.jsx | 21 +- .../CourseCardActions/ResumeButton.test.jsx | 50 +- .../CourseCardActions/SelectSessionButton.jsx | 7 +- .../SelectSessionButton.test.jsx | 16 +- .../CourseCardActions/ViewCourseButton.jsx | 10 +- .../ViewCourseButton.test.jsx | 22 +- .../components/CourseCardActions/index.jsx | 13 +- .../CourseCardActions/index.test.jsx | 35 +- .../CourseCardBanners/CertificateBanner.jsx | 45 +- .../CertificateBanner.test.jsx | 69 +- .../CourseCardBanners/CourseBanner.jsx | 22 +- .../CourseCardBanners/CourseBanner.test.jsx | 34 +- .../CourseCardBanners/CreditBanner/hooks.js | 35 +- .../CreditBanner/hooks.test.js | 26 +- .../CreditBanner/views/ApprovedContent.jsx | 17 +- .../views/ApprovedContent.test.jsx | 28 +- .../CreditBanner/views/EligibleContent.jsx | 9 +- .../views/EligibleContent.test.jsx | 18 +- .../CreditBanner/views/MustRequestContent.jsx | 7 +- .../views/MustRequestContent.test.jsx | 29 +- .../CreditBanner/views/PendingContent.jsx | 11 +- .../views/PendingContent.test.jsx | 28 +- .../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 | 23 +- .../CreditBanner/views/hooks.test.js | 56 -- .../CreditBanner/views/hooks.test.tsx | 189 ++++ .../CourseCardBanners/EntitlementBanner.jsx | 22 +- .../EntitlementBanner.test.jsx | 83 +- .../RelatedProgramsBanner/index.jsx | 12 +- .../RelatedProgramsBanner/index.test.jsx | 12 +- .../components/CourseCardBanners/index.jsx | 10 +- .../CourseCardBanners/index.test.jsx | 37 +- .../components/CourseCardDetails/hooks.js | 33 +- .../CourseCardDetails/hooks.test.js | 64 +- .../CourseCard/components/CourseCardImage.jsx | 18 +- .../components/CourseCardImage.test.jsx | 55 +- .../CourseCardMenu/SocialShareMenu.jsx | 26 +- .../CourseCardMenu/SocialShareMenu.test.jsx | 58 +- .../components/CourseCardMenu/hooks.js | 40 +- .../components/CourseCardMenu/hooks.test.js | 69 +- .../components/CourseCardMenu/index.jsx | 15 +- .../components/CourseCardMenu/index.test.jsx | 40 +- .../CourseCard/components/CourseCardTitle.jsx | 11 +- .../components/CourseCardTitle.test.jsx | 24 +- .../components/RelatedProgramsBadge/hooks.jsx | 5 +- .../RelatedProgramsBadge/hooks.test.js | 24 +- src/containers/CourseCard/components/hooks.js | 20 +- .../CourseCard/components/hooks.test.js | 84 +- src/containers/CourseCard/hooks.js | 17 - src/containers/CourseCard/hooks.test.js | 72 +- .../ActiveCourseFilters.jsx | 17 +- .../ActiveCourseFilters.test.jsx | 40 +- .../CourseFilterControls.jsx | 67 +- .../CourseFilterControls.test.jsx | 157 +++- src/containers/CourseFilterControls/hooks.js | 60 -- .../CourseFilterControls/hooks.test.js | 125 --- .../CoursesPanel/CourseList/index.jsx | 6 +- .../CoursesPanel/NoCoursesView/index.jsx | 9 +- .../CoursesPanel/NoCoursesView/index.test.jsx | 14 +- src/containers/CoursesPanel/hooks.js | 54 -- src/containers/CoursesPanel/hooks.test.js | 115 --- src/containers/CoursesPanel/index.jsx | 48 +- src/containers/CoursesPanel/index.test.jsx | 147 +++- .../Dashboard/DashboardLayout.test.jsx | 25 +- src/containers/Dashboard/hooks.js | 1 + src/containers/Dashboard/hooks.test.js | 17 +- src/containers/Dashboard/index.jsx | 49 +- src/containers/Dashboard/index.test.jsx | 32 +- src/containers/EmailSettingsModal/hooks.js | 13 +- .../EmailSettingsModal/hooks.test.js | 71 -- .../EmailSettingsModal/hooks.test.jsx | 134 +++ src/containers/RelatedProgramsModal/hooks.js | 10 - src/containers/RelatedProgramsModal/index.jsx | 8 +- .../RelatedProgramsModal/index.test.jsx | 21 +- src/containers/SelectSessionModal/hooks.js | 43 +- .../SelectSessionModal/hooks.test.js | 204 ----- .../SelectSessionModal/hooks.test.jsx | 288 ++++++ .../components/ConfirmPane.jsx | 5 +- .../components/ConfirmPane.test.jsx | 10 + .../components/FinishedPane.jsx | 6 +- .../components/FinishedPane.test.jsx | 10 + .../UnenrollConfirmModal/hooks/index.js | 12 +- .../UnenrollConfirmModal/hooks/index.test.js | 107 +-- .../UnenrollConfirmModal/hooks/reasons.js | 24 +- .../hooks/reasons.test.js | 178 ---- .../hooks/reasons.test.jsx | 262 ++++++ .../UnenrollConfirmModal/index.test.jsx | 9 +- src/data/constants/app.test.js | 163 ++++ src/data/constants/files.js | 2 +- src/data/constants/htmlKeys.js | 2 +- 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 | 60 ++ src/data/context/index.tsx | 21 + src/data/contexts/MasqueradeUserContext.jsx | 16 - src/data/contexts/MasqueradeUserProvider.jsx | 31 - src/data/hooks/index.ts | 19 + src/data/hooks/mutationHooks.test.tsx | 349 ++++++++ src/data/hooks/mutationHooks.ts | 131 +++ src/data/hooks/queryHooks.test.tsx | 153 ++++ src/data/hooks/queryHooks.ts | 52 ++ 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 | 60 -- .../redux/app/selectors/currentList.test.js | 187 ---- 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 | 106 --- src/data/redux/hooks/index.js | 2 - src/data/redux/hooks/requests.js | 47 - src/data/redux/index.js | 37 - src/data/redux/requests/index.js | 2 - src/data/redux/requests/reducer.js | 53 -- src/data/redux/requests/reducer.test.js | 62 -- src/data/redux/requests/selectors.js | 28 - src/data/redux/requests/selectors.test.js | 82 -- src/data/services/lms/api.js | 77 -- src/data/services/lms/api.test.js | 156 ---- src/data/services/lms/api.test.tsx | 285 ++++++ 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/services/lms/index.js | 2 +- src/data/store.js | 25 - src/data/store.test.js | 47 - src/data/utils.js | 19 - src/data/utils.test.js | 29 - src/hooks/api.js | 123 --- src/hooks/api.test.js | 268 ------ 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/providers.ts | 6 +- src/setupTest.jsx | 18 - src/slots/WidgetSidebarSlot/index.test.jsx | 14 +- src/test/app.test.jsx | 245 ------ src/test/inspector.js | 44 - src/test/messages.js | 29 - src/test/utils.js | 3 - src/tracking/trackers/socialShare.js | 4 +- src/utils/dataTransformers.test.ts | 629 +++++++++++++ src/utils/dataTransformers.ts | 67 ++ src/utils/hooks.test.tsx | 55 ++ .../ConfirmEmailBanner/hooks.js | 16 +- .../ConfirmEmailBanner/hooks.test.jsx | 111 +++ .../MasqueradeBar/hooks.js | 66 +- .../MasqueradeBar/index.jsx | 2 +- src/widgets/LearnerDashboardHeader/hooks.js | 2 +- .../LookingForChallengeWidget/index.jsx | 5 +- .../LookingForChallengeWidget/index.test.jsx | 14 +- tsconfig.json | 1 + 179 files changed, 9039 insertions(+), 5864 deletions(-) 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/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 delete mode 100644 src/data/contexts/MasqueradeUserContext.jsx delete mode 100644 src/data/contexts/MasqueradeUserProvider.jsx 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 create mode 100644 src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.jsx diff --git a/jest.config.js b/jest.config.js index f55dab329..0f268cabf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,22 +1,21 @@ const { createConfig } = require('@openedx/frontend-base/tools'); -module.exports = createConfig('test', { +const config = createConfig('test', { setupFilesAfterEnv: [ 'jest-expect-message', '/src/setupTest.jsx', ], coveragePathIgnorePatterns: [ 'src/segment.js', - 'src/postcss.config.js', 'testUtils', // don't unit test jest mocking tools - 'src/data/services/lms/fakeData', // don't unit test mock data - 'src/test', // don't unit test integration test utils 'src/__mocks__', ], moduleNameMapper: { - // Asset mocks '\\.svg$': '/src/__mocks__/svg.js', - '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/__mocks__/file.js', + '\\.png$': '/src/__mocks__/file.js', + '^@src/(.*)$': '/src/$1', }, testTimeout: 120000, }); + +module.exports = config; diff --git a/package-lock.json b/package-lock.json index cdf67427d..8e187672a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,17 +15,12 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.2.0", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^5.2.2", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", - "reselect": "^4.0.0" + "react-share": "^5.2.2" }, "devDependencies": { "@edx/browserslist-config": "^1.5.0", @@ -38,8 +33,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", "tsc-alias": "^1.8.16" }, "peerDependencies": { @@ -50,10 +43,8 @@ "@types/react-dom": "^18", "react": "^18", "react-dom": "^18", - "react-redux": "^8", "react-router": "^6", - "react-router-dom": "^6", - "redux": "^4" + "react-router-dom": "^6" } }, "node_modules/@adobe/css-tools": { @@ -4956,67 +4947,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@redux-devtools/extension": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz", - "integrity": "sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2", - "immutable": "^4.3.4" - }, - "peerDependencies": { - "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit/node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -5071,18 +5001,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/@stylistic/eslint-plugin": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", @@ -5693,12 +5611,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", - "license": "MIT" - }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -8359,13 +8271,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", @@ -11211,22 +11116,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "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", @@ -13582,13 +13471,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "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.keyby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.keyby/-/lodash.keyby-4.6.0.tgz", @@ -16655,51 +16537,6 @@ "integrity": "sha512-nopsRn7KnGgazBe2c3H2+Kf+Csp6PGDRLiBkYEDMKY8o/EIgft/WnIm/OnAKTawZiLnJXHAqhpFBddvs6NiXlw==", "license": "MIT" }, - "node_modules/react-redux": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", - "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/use-sync-external-store": "^0.0.3", - "hoist-non-react-statics": "^3.3.2", - "react-is": "^18.0.0", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "@types/react": "^16.8 || ^17.0 || ^18.0", - "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0", - "react-native": ">=0.59", - "redux": "^4 || ^5.0.0-beta.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-redux/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", @@ -16819,20 +16656,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-shallow-renderer": { - "version": "16.15.0", - "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", - "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1", - "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-share": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.2.2.tgz", @@ -16881,28 +16704,6 @@ "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" } }, - "node_modules/react-test-renderer": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", - "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-is": "^18.3.1", - "react-shallow-renderer": "^16.15.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-test-renderer/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -17033,47 +16834,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@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-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -17227,12 +16987,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -20175,15 +19929,6 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index 28752f4ec..1aebef183 100644 --- a/package.json +++ b/package.json @@ -46,17 +46,12 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.2.0", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^5.2.2", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", - "reselect": "^4.0.0" + "react-share": "^5.2.2" }, "devDependencies": { "@edx/browserslist-config": "^1.5.0", @@ -69,8 +64,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", "tsc-alias": "^1.8.16" }, "peerDependencies": { @@ -81,9 +74,7 @@ "@types/react-dom": "^18", "react": "^18", "react-dom": "^18", - "react-redux": "^8", "react-router": "^6", - "react-router-dom": "^6", - "redux": "^4" + "react-router-dom": "^6" } } diff --git a/src/Main.jsx b/src/Main.jsx index 94233d2f9..3cafc12aa 100644 --- a/src/Main.jsx +++ b/src/Main.jsx @@ -1,19 +1,18 @@ -import { Provider as ReduxProvider } from 'react-redux'; import { CurrentAppProvider, PageWrap } from '@openedx/frontend-base'; import { appId } from './constants'; -import store from './data/store'; +import ContextProviders from './data/context'; import Dashboard from './containers/Dashboard'; import './app.scss'; const Main = () => ( - + - + ); diff --git a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx index 19ee67481..f888bbf56 100644 --- a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx @@ -1,22 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/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 7548be160..457b69a05 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; import useActionDisabledState from '../hooks'; import BeginCourseButton from './BeginCourseButton'; +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId: 'test-org-id', + }, + }, + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/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 12fb56a3f..9df845da0 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx @@ -1,22 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/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 ad3515a7e..da407402f 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 '@openedx/frontend-base'; +import { useCourseTrackingEvent, useCourseData } from '@src/hooks'; -import { reduxHooks } from '@src/hooks'; import track from '@src/tracking'; import useActionDisabledState from '../hooks'; import ResumeButton from './ResumeButton'; +const authOrgId = 'auth-org-id'; +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId, + }, + }, + }), +})); + +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/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 f4283dc61..6e11434f0 100644 --- a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx @@ -3,8 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../hooks'; - +import { useSelectSessionModal } from '@src/data/context'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; @@ -12,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 45bdd2c24..edbce2241 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 '@openedx/frontend-base'; +import { useSelectSessionModal } from '@src/data/context'; -import { reduxHooks } from '@src/hooks'; import useActionDisabledState from '../hooks'; import SelectSessionButton from './SelectSessionButton'; -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useUpdateSelectSessionModalCallback: jest.fn(), - }, +jest.mock('@src/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 5606689bf..888ae059a 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx @@ -3,19 +3,19 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData } from '@src/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 3666ecad6..73694b9b2 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 '@openedx/frontend-base'; +import { useCourseTrackingEvent } from '@src/hooks'; import track from '@src/tracking'; -import { reduxHooks } from '@src/hooks'; import useActionDisabledState from '../hooks'; import ViewCourseButton from './ViewCourseButton'; +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + courseRun: { homeUrl: 'homeUrl' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/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 50a08e69b..21d168951 100644 --- a/src/containers/CourseCard/components/CourseCardActions/index.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/index.jsx @@ -3,20 +3,19 @@ import PropTypes from 'prop-types'; import { ActionRow } from '@openedx/paragon'; -import { reduxHooks } from '../../../../hooks'; -import CourseCardActionSlot from '../../../../slots/CourseCardActionSlot'; +import { useCourseData, useEntitlementInfo } from '@src/hooks'; +import CourseCardActionSlot from '@src/slots/CourseCardActionSlot'; import SelectSessionButton from './SelectSessionButton'; import BeginCourseButton from './BeginCourseButton'; 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 5b133444c..7a02de45b 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 '@src/hooks'; - +import { useCourseData } from '@src/hooks'; import CourseCardActions from '.'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useMasqueradeData: jest.fn(), - }, + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), })); jest.mock('@src/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 59a6a1fe1..e5491d19d 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx @@ -1,28 +1,47 @@ /* 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 '@openedx/frontend-base'; +import { baseAppUrl } from '@src/data/services/lms/urls'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { utilHooks, useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; 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(); @@ -31,7 +50,7 @@ export const CertificateBanner = ({ cardId }) => { if (certificate.isRestricted) { return ( - {supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)} + { supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)} {isVerified && ' '} {isVerified && (billingEmail ? formatMessage(messages.certRefundContactBilling, { billingEmail: emailLink(billingEmail) }) : formatMessage(messages.certRefundContactBillingNoEmail))} @@ -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 452cd0dd4..021ce1198 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import CertificateBanner from './CertificateBanner'; jest.mock('@src/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('@src/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 266c37532..f06310801 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx @@ -1,22 +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 '@openedx/frontend-base'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; - +import { utilHooks, useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; import messages from './messages'; export const CourseBanner = ({ cardId }) => { + const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); const { - isVerified, - isAuditAccessExpired, + isVerified = false, + isAuditAccessExpired = false, coursewareAccess = {}, - } = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); - const { formatMessage } = useIntl(); + } = useMemo(() => ({ + isVerified: courseData.enrollment?.isVerified, + isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired, + coursewareAccess: courseData.enrollment?.coursewareAccess || {}, + }), [courseData]); + const courseRun = courseData?.courseRun || {}; const formatDate = utilHooks.useFormatDate(); const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx index 077681739..dbf407ca0 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import { formatMessage } from '@src/testUtils'; import { CourseBanner } from './CourseBanner'; import messages from './messages'; jest.mock('@src/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 0f4ff0640..80d06d3df 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js @@ -1,5 +1,8 @@ -import { StrictDict } from '../../../../../utils'; -import { reduxHooks } from '../../../../../hooks'; +import { useMemo } from 'react'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { StrictDict } from '@src/utils'; + +import { useCourseData } from '@src/hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -14,11 +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 b73ec4c03..1aeaf7dc6 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 '@src/utils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -9,12 +10,19 @@ import RejectedContent from './views/RejectedContent'; import * as hooks from './hooks'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, + useCourseData: jest.fn(), })); + +jest.mock('@src/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 1f6143d6b..9df635f68 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, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData, useIsMasquerading } from '@src/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 } = useContext(MasqueradeUserContext); + 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(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -17,28 +15,21 @@ const credit = { providerStatusUrl: 'test-credit-provider-status-url', providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); - -const renderWithMasquerading = (isMasquerading = false) => render( - - - - - -); +useCourseData.mockReturnValue({ credit }); +useIsMasquerading.mockReturnValue(false); describe('ApprovedContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { - renderWithMasquerading(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + render(); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { describe('rendered CreditContent component', () => { beforeEach(() => { jest.clearAllMocks(); - renderWithMasquerading(); + render(); }); it('action.message is formatted viewCredit message', () => { const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage }); @@ -63,7 +54,8 @@ describe('ApprovedContent component', () => { }); describe('when masquerading', () => { beforeEach(() => { - renderWithMasquerading(true); + useIsMasquerading.mockReturnValue(true); + render(); }); it('disables the action button', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx index 24c40eff2..f156e1c7b 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx @@ -3,16 +3,17 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../../hooks'; -import track from '../../../../../../tracking'; +import { useCourseData } from '@src/hooks'; +import track from '@src/tracking'; import CreditContent from './components/CreditContent'; 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 067236ce3..8360c4d8a 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import track from '@src/tracking'; import messages from './messages'; import EligibleContent from './EligibleContent'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + useCourseData: jest.fn(), })); jest.mock('@src/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 ea61b9335..e849e7e03 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx @@ -1,8 +1,9 @@ +import React from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; +import { useIsMasquerading } from '@src/hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import hooks from './hooks'; @@ -12,7 +13,7 @@ import messages from './messages'; export const MustRequestContent = ({ cardId }) => { const { formatMessage } = useIntl(); const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); + const isMasquerading = useIsMasquerading(); return ( ({ })); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -31,11 +28,9 @@ const providerName = 'test-credit-provider-name'; const providerStatusUrl = 'test-credit-provider-status-url'; const createCreditRequest = jest.fn().mockName('createCreditRequest'); -const renderMustRequestContent = (isMasquerading = false) => render( +const renderMustRequestContent = () => render( - - - + , ); @@ -46,9 +41,12 @@ describe('MustRequestContent component', () => { requestData, createCreditRequest, }); - reduxHooks.useCardCreditData.mockReturnValue({ - providerName, - providerStatusUrl, + useIsMasquerading.mockReturnValue(false); + useCourseData.mockReturnValue({ + credit: { + providerName, + providerStatusUrl, + }, }); }); @@ -91,13 +89,14 @@ describe('MustRequestContent component', () => { describe('when masquerading', () => { beforeEach(() => { - renderMustRequestContent(true); + useIsMasquerading.mockReturnValue(true); + renderMustRequestContent(); }); it('disables the request credit button', () => { const button = screen.getByRole('button', { name: /request credit/i }); - expect(button).toHaveAttribute('aria-disabled', 'true'); expect(button).toHaveClass('disabled'); + expect(button).toHaveAttribute('aria-disabled', 'true'); }); }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx index b0ff47836..b9c72fd30 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx @@ -1,15 +1,16 @@ +import React from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData, useIsMasquerading } from '@src/hooks'; import CreditContent from './components/CreditContent'; import messages from './messages'; export const PendingContent = ({ cardId }) => { - const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); + const courseData = useCourseData(cardId); + const { providerStatusUrl: href, providerName } = courseData?.credit || {}; + const isMasquerading = useIsMasquerading(); const { formatMessage } = useIntl(); return ( ({ - reduxHooks: { useCardCreditData: 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, + }, }); -const renderPendingContent = (isMasquerading = false) => render( +const renderPendingContent = () => render( - - - + , ); describe('PendingContent component', () => { describe('hooks', () => { it('initializes card credit data with cardId', () => { renderPendingContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('behavior', () => { @@ -58,9 +58,9 @@ describe('PendingContent component', () => { }); describe('when masqueradeData is true', () => { it('disables the view details button', () => { - renderPendingContent(true); + useIsMasquerading.mockReturnValue(true); + renderPendingContent(); const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage }); - expect(button).toHaveAttribute('aria-disabled', 'true'); 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 27c747baf..9b9abb205 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 '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData } from '@src/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 8eb08962e..ce0877477 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import RejectedContent from './RejectedContent'; jest.mock('@src/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 5bc7467bf..f1ac52717 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 '@src/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 d0eff0b84..90f73656b 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 { StrictDict } from '../../../../../../utils'; -import { apiHooks } from '../../../../../../hooks'; +import { useAuthenticatedUser } from '@openedx/frontend-base'; +import { StrictDict } from '@src/utils'; +import { useCourseData } from '@src/hooks'; +import { useCreateCreditRequest } from '@src/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 { username } = useAuthenticatedUser(); + 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 4d2e6852b..000000000 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { MockUseState } from '@src/testUtils'; -import { apiHooks } from '@src/hooks'; -import * as hooks from './hooks'; - -jest.mock('@src/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 000000000..f721432db --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx @@ -0,0 +1,189 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import * as api from '@src/data/services/lms/api'; +import { useCourseData } from '@src/hooks'; +import { useAuthenticatedUser } from '@openedx/frontend-base'; +import * as hooks from './hooks'; + +jest.mock('@src/data/services/lms/api', () => ({ + createCreditRequest: jest.fn(), +})); + +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn(), +})); + +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + logError: jest.fn(), + useAuthenticatedUser: 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(); + (useAuthenticatedUser as jest.Mock).mockReturnValue({ username: 'test-user' }); + (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 d51d28598..47204692f 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 '@openedx/frontend-base'; import { Button, MailtoLink } from '@openedx/paragon'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; +import { utilHooks, useCourseData, useEntitlementInfo } from '@src/hooks'; +import { useSelectSessionModal } from '@src/data/context'; +import Banner from '@src/components/Banner'; +import { useInitializeLearnerHome } from '@src/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 7c96738fa..4f315dc64 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 '@openedx/frontend-base'; import { formatMessage } from '@src/testUtils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import EntitlementBanner from './EntitlementBanner'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + platformSettings: { + supportEmail: 'test-support-email', + }, + }, + }), +})); +const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal'); +jest.mock('@src/data/context/SelectSessionProvider', () => ({ + useSelectSessionModal: () => ({ + updateSelectSessionModal: mockUpdateSelectSessionModal, + }), +})); + jest.mock('@src/hooks', () => ({ + ...jest.requireActual('@src/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 ee213f0c2..63d683eca 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx @@ -4,18 +4,18 @@ import PropTypes from 'prop-types'; import { Program } from '@openedx/paragon/icons'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../hooks'; -import Banner from '../../../../../components/Banner'; +import { useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; import ProgramList from './ProgramsList'; 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 298a1f193..84ac74cf8 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 '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import RelatedProgramsBanner from '.'; jest.mock('@src/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 77eff4592..0465b464f 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.jsx @@ -1,16 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { reduxHooks } from '../../../../hooks'; -import CourseBannerSlot from '../../../../slots/CourseBannerSlot'; +import { useCourseData } from '@src/hooks'; +import CourseBannerSlot from '@src/slots/CourseBannerSlot'; import CertificateBanner from './CertificateBanner'; import CreditBanner from './CreditBanner'; 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 e6097f1c6..2c259cb4c 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx @@ -1,8 +1,9 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; + +import { useCourseData } from '@src/hooks'; -import { reduxHooks } from '@src/hooks'; import CourseCardBanners from '.'; jest.mock('./CourseBanner', () => jest.fn(() =>
CourseBanner
)); @@ -20,9 +21,11 @@ const mockedComponents = [ ]; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })), - }, + useCourseData: jest.fn(() => ({ + enrollment: { + isEnrolled: true, + }, + })), })); describe('CourseCardBanners', () => { @@ -30,28 +33,20 @@ describe('CourseCardBanners', () => { cardId: 'test-card-id', }; it('renders default CourseCardBanners', () => { - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: true }); - render( - - - - - - ); + render(); mockedComponents.map((componentName) => { const mockedComponent = screen.getByText(componentName); 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 }); - render( - - - - - - ); + useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } }); + render(); const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2); mockedComponentsIfNotEnrolled.map((componentName) => { const mockedComponent = screen.getByText(componentName); diff --git a/src/containers/CourseCard/components/CourseCardDetails/hooks.js b/src/containers/CourseCard/components/CourseCardDetails/hooks.js index 2ff2a6778..54f63e001 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.js @@ -1,22 +1,21 @@ import { useIntl } from '@openedx/frontend-base'; -import { utilHooks, reduxHooks } from '../../../../hooks'; +import { utilHooks, useCourseData, useEntitlementInfo } from '@src/hooks'; +import { useSelectSessionModal } from '@src/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 ?? formatDate(courseRun.startDate); + 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, @@ -29,9 +28,7 @@ export const useAccessMessage = ({ cardId }) => { { accessExpirationDate: formatDate(accessExpirationDate) }, ); } - if (!endDate) { - return null; - } + if (!endDate) { return null; } return formatMessage( isArchived ? messages.courseEnded : messages.courseEnds, { endDate: formatDate(endDate) }, @@ -42,23 +39,23 @@ 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), + providerName: providerName || formatMessage(messages.unknownProviderName), accessMessage: hooks.useAccessMessage({ 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 13b193192..86c4969ad 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js @@ -1,22 +1,26 @@ import { useIntl } from '@openedx/frontend-base'; import { keyStore } from '@src/utils'; -import { utilHooks, reduxHooks } from '@src/hooks'; +import { utilHooks, useCourseData } from '@src/hooks'; +import { useSelectSessionModal } from '@src/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('@src/data/context', () => ({ + useSelectSessionModal: jest.fn(), +})); jest.mock('@src/hooks', () => ({ + ...jest.requireActual('@src/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('@openedx/frontend-base', () => { @@ -45,8 +49,9 @@ describe('CourseCardDetails hooks', () => { }); describe('useCardDetailsData', () => { - const providerName = 'my-provider-name'; - const providerData = {}; + const providerData = { + name: 'my-provider-name', + }; const entitlementData = { isEntitlement: false, disableViewCourse: false, @@ -58,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(() => { @@ -76,15 +79,17 @@ describe('CourseCardDetails hooks', () => { expect(out.accessMessage).toEqual(mockAccessMessage({ cardId })); }); it('forwards provider name if it exists, else formatted unknown provider name', () => { - runHook({ provider: { name: providerName } }); - expect(out.providerName).toEqual(providerName); - - runHook({ provider: {} }); + expect(out.providerName).toEqual(providerData.name); + runHook({ provider: { name: '' } }); expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName)); }); 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 93a085f7e..8c2bce6fd 100644 --- a/src/containers/CourseCard/components/CourseCardImage.jsx +++ b/src/containers/CourseCard/components/CourseCardImage.jsx @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; +import { baseAppUrl } from '@src/data/services/lms/urls'; import { Badge } from '@openedx/paragon'; -import track from '../../../tracking'; -import { reduxHooks } from '../../../hooks'; -import verifiedRibbon from '../../../assets/verified-ribbon.png'; +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import verifiedRibbon from '@src/assets/verified-ribbon.png'; import useActionDisabledState from './hooks'; import messages from '../messages'; @@ -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 f0b616e5a..3b00eb6f6 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx @@ -1,14 +1,13 @@ -import { useContext } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import * as ReactShare from 'react-share'; - +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; import { useIntl } from '@openedx/frontend-base'; import { Dropdown } from '@openedx/paragon'; -import MasqueradeUserContext from '../../../../data/contexts/MasqueradeUserContext'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from '@src/hooks'; +import { useCardSocialSettingsData } from './hooks'; import messages from './messages'; export const testIds = { @@ -17,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 } = useContext(MasqueradeUserContext); - - 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 4a6e6fa63..7a2f24e0f 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx @@ -1,11 +1,12 @@ +import { when } from 'jest-when'; + import { IntlProvider } from '@openedx/frontend-base'; import { render, screen } from '@testing-library/react'; -import { when } from 'jest-when'; + import track from '@src/tracking'; -import { reduxHooks } from '@src/hooks'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from '@src/hooks'; -import { useEmailSettings } from './hooks'; +import { useEmailSettings, useCardSocialSettingsData } from './hooks'; import SocialShareMenu from './SocialShareMenu'; import messages from './messages'; @@ -14,15 +15,13 @@ jest.mock('@src/tracking', () => ({ })); jest.mock('@src/hooks', () => ({ - reduxHooks: { - 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 = { @@ -55,31 +54,28 @@ 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.useCardSocialSettingsData, + useCardSocialSettingsData, { facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled }, twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled }, }, { isCardHook: true }, ); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); }; -const renderComponent = (isMasquerading = false) => render( - - - - - , -); +const renderComponent = () => render(); describe('SocialShareMenu', () => { describe('behavior', () => { @@ -90,12 +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.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', () => { @@ -118,9 +114,7 @@ describe('SocialShareMenu', () => { if (isMasquerading) { it('is disabled', () => { const emailSettingsButton = screen.getByRole('button', { name: messages.emailSettings.defaultMessage }); - expect(emailSettingsButton).toBeInTheDocument(); expect(emailSettingsButton).toHaveAttribute('aria-disabled', 'true'); - expect(emailSettingsButton).toHaveClass('disabled'); }); } else { it('is enabled', () => { @@ -171,8 +165,8 @@ describe('SocialShareMenu', () => { }); describe('masquerading', () => { beforeEach(() => { - mockHooks({ isEmailEnabled: true }); - renderComponent(true); + mockHooks({ isEmailEnabled: true, isMasquerading: true }); + renderComponent(); }); testEmailSettingsDropdown(true); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/hooks.js b/src/containers/CourseCard/components/CourseCardMenu/hooks.js index 4b1ed524b..17ac2a97d 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/hooks.js +++ b/src/containers/CourseCard/components/CourseCardMenu/hooks.js @@ -1,8 +1,8 @@ +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; import { useState } from 'react'; - -import { reduxHooks } from '../../../../hooks'; -import track from '../../../../tracking'; -import { StrictDict } from '../../../../utils'; +import { StrictDict } from '@src/utils'; +import { useInitializeLearnerHome } from '@src/data/hooks'; export const state = StrictDict({ isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line @@ -28,21 +28,39 @@ export const useEmailSettings = () => { }; export const useHandleToggleDropdown = (cardId) => { - const trackCourseEvent = reduxHooks.useTrackCourseEvent( + const trackCourseEvent = useCourseTrackingEvent( track.course.courseOptionsDropdownClicked, cardId, ); return (isOpen) => { - if (isOpen) { - trackCourseEvent(); - } + if (isOpen) { trackCourseEvent(); } }; }; +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 5581b98da..5cdd4138b 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 '@src/hooks'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import track from '@src/tracking'; import { MockUseState } from '@src/testUtils'; import * as hooks from './hooks'; +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + jest.mock('@src/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; @@ -68,12 +69,10 @@ describe('CourseCardMenu hooks', () => { }); describe('useHandleToggleDropdown', () => { - beforeEach(() => { - out = hooks.useHandleToggleDropdown(cardId); - }); + 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, ); @@ -90,55 +89,61 @@ describe('CourseCardMenu hooks', () => { }); describe('useOptionVisibility', () => { - const mockReduxHooks = (returnVals = {}) => { - reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({ - facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, - twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - isEnrolled: !!returnVals.isEnrolled, - isEmailEnabled: !!returnVals.isEmailEnabled, - }); - reduxHooks.useCardCertificateData.mockReturnValueOnce({ - isEarned: !!returnVals.isEarned, + const mockHooks = (returnVals = {}) => { + useInitializeLearnerHome.mockReturnValue({ + data: { + socialShareSettings: { + facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, + twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, + }, + }, + }); + useCourseData.mockReturnValue({ + enrollment: { + isEnrolled: !!returnVals.isEnrolled, + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + certificate: { + isEarned: !!returnVals.isEarned, + }, }); }; describe('shouldShowUnenrollItem', () => { it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true); }); it('returns false if not enrolled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); it('returns false if enrolled but also earned', () => { - mockReduxHooks({ isEarned: true }); + mockHooks({ isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); }); describe('shouldShowDropdown', () => { it('returns false if not enrolled and both email and socials are disabled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns false if enrolled but already earned, and both email and socials are disabled', () => { - mockReduxHooks({ isEnrolled: true, isEarned: true }); + mockHooks({ isEnrolled: true, isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns true if either social is enabled', () => { - mockReduxHooks({ facebook: { isEnabled: true } }); + mockHooks({ facebook: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); - mockReduxHooks({ twitter: { isEnabled: true } }); + mockHooks({ twitter: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if email is enabled', () => { - mockReduxHooks({ isEmailEnabled: true }); + mockHooks({ isEmailEnabled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.jsx index d2d7d1992..29d43f42d 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.jsx @@ -1,15 +1,12 @@ -import { useContext } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; import { Dropdown, Icon, IconButton } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; -import MasqueradeUserContext from '../../../../data/contexts/MasqueradeUserContext'; -import EmailSettingsModal from '../../../../containers/EmailSettingsModal'; -import UnenrollConfirmModal from '../../../../containers/UnenrollConfirmModal'; -import { reduxHooks } from '../../../../hooks'; - +import EmailSettingsModal from '@src/containers/EmailSettingsModal'; +import UnenrollConfirmModal from '@src/containers/UnenrollConfirmModal'; +import { useCourseData, useIsMasquerading } from '@src/hooks'; import SocialShareMenu from './SocialShareMenu'; import { useEmailSettings, @@ -26,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 } = useContext(MasqueradeUserContext); - 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 db3aed076..c0405da53 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx @@ -1,17 +1,17 @@ import { when } from 'jest-when'; + import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; + +import { useCourseData, useIsMasquerading } from '@src/hooks'; import * as hooks from './hooks'; import CourseCardMenu from '.'; import messages from './messages'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardEnrollmentData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); jest.mock('./SocialShareMenu', () => jest.fn(() =>
SocialShareMenu
)); jest.mock('@src/containers/EmailSettingsModal', () => jest.fn(() =>
EmailSettingsModal
)); @@ -67,20 +67,19 @@ const mockHooks = (returnVals = {}) => { }, { isCardHook: true }, ); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); mockHook( - reduxHooks.useCardEnrollmentData, - { isEmailEnabled: !!returnVals.isEmailEnabled }, + useCourseData, + { + enrollment: { + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + }, { isCardHook: true }, ); }; -const renderComponent = (isMasquerading = false) => render( - - - - - -); +const renderComponent = () => render(); describe('CourseCardMenu', () => { describe('hooks', () => { @@ -90,12 +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.useCardEnrollmentData).expectCalledWith(props.cardId); + it('initializes hook data ', () => { + when(useIsMasquerading).expectCalledWith(); + when(useCourseData).expectCalledWith(props.cardId); }); }); describe('render', () => { @@ -155,13 +152,14 @@ describe('CourseCardMenu', () => { }); describe('masquerading', () => { it('renders but unenroll is disabled', async () => { - mockHooks({ ...hookProps }); - renderComponent(true); + mockHooks({ ...hookProps, isMasquerading: true }); + renderComponent(); const user = userEvent.setup(); const dropdown = screen.getByRole('button', { name: messages.dropdownAlt.defaultMessage }); expect(dropdown).toBeInTheDocument(); await user.click(dropdown); + const unenrollOption = screen.getByRole('button', { name: messages.unenroll.defaultMessage }); expect(unenrollOption).toBeInTheDocument(); expect(unenrollOption).toHaveAttribute('aria-disabled', 'true'); diff --git a/src/containers/CourseCard/components/CourseCardTitle.jsx b/src/containers/CourseCard/components/CourseCardTitle.jsx index 39277f29a..475551062 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.jsx @@ -1,16 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; -import track from '../../../tracking'; -import { reduxHooks } from '../../../hooks'; +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/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 a6c45ca2c..62086f5fa 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.test.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.test.jsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import track from '@src/tracking'; import useActionDisabledState from './hooks'; import CourseCardTitle from './CourseCardTitle'; -import track from '@src/tracking'; jest.mock('@src/tracking', () => ({ course: { @@ -12,11 +12,8 @@ jest.mock('@src/tracking', () => ({ })); jest.mock('@src/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 d14831b70..9b988e359 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 '@openedx/frontend-base'; import { StrictDict } from '@src/utils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/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 c9ee1445d..cd170b27b 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js @@ -1,16 +1,15 @@ import { useIntl } from '@openedx/frontend-base'; import { MockUseState } from '@src/testUtils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import * as hooks from './hooks'; import messages from './messages'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardRelatedProgramsData: jest.fn(), - }, + useCourseData: jest.fn(), })); + jest.mock('@openedx/frontend-base', () => { const { formatMessage } = jest.requireActual('@src/testUtils'); return { @@ -24,7 +23,7 @@ jest.mock('@openedx/frontend-base', () => { const cardId = 'test-card-id'; const state = new MockUseState(hooks); -let numPrograms = 27; +const numPrograms = 27; describe('RelatedProgramsBadge hooks', () => { const { formatMessage } = useIntl(); @@ -34,15 +33,14 @@ describe('RelatedProgramsBadge hooks', () => { }); beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ - length: numPrograms, - }); }); describe('useRelatedProgramsBadgeData', () => { beforeEach(() => { state.mock(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ - length: numPrograms, + useCourseData.mockReturnValue({ + programs: { + relatedPrograms: new Array(numPrograms).fill({}), + }, }); out = hooks.useRelatedProgramsBadgeData({ cardId }); }); @@ -66,14 +64,12 @@ describe('RelatedProgramsBadge hooks', () => { expect(out.numPrograms).toEqual(numPrograms); }); test('returns empty programsMessage if no programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReset(); - 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.mockReset(); - 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 5fdacc7f6..d108b82cf 100644 --- a/src/containers/CourseCard/components/hooks.js +++ b/src/containers/CourseCard/components/hooks.js @@ -1,19 +1,19 @@ -import { useContext } from 'react'; - -import MasqueradeUserContext from '../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../hooks'; +import { useCourseData, useEntitlementInfo, useIsMasquerading } from '@src/hooks'; export const useActionDisabledState = (cardId) => { - const { isMasquerading } = useContext(MasqueradeUserContext); + 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 5a27dd44f..7f02f172e 100644 --- a/src/containers/CourseCard/components/hooks.test.js +++ b/src/containers/CourseCard/components/hooks.test.js @@ -1,16 +1,15 @@ -import { reduxHooks } from '@src/hooks'; - +import { useCourseData, useIsMasquerading } from '@src/hooks'; import * as hooks from './hooks'; -import { renderHook } from '@testing-library/react'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: jest.fn((fn) => fn()), +})); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'my-test-course-number'; @@ -40,39 +39,43 @@ 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, + }, }); }; - const runHook = (masqueradeValue = { isMasquerading: false }) => { - const { result } = renderHook(() => hooks.useActionDisabledState(cardId), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - return result.current; - }; + beforeEach(() => { + jest.clearAllMocks(); + }); + + const runHook = () => hooks.useActionDisabledState(cardId); describe('disableBeginCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableBeginCourse).toBe(expected); + expect(runHook().disableBeginCourse).toBe(expected); }; it('disable when homeUrl is invalid', () => { testDisabled({ homeUrl: null }, true); @@ -93,7 +96,7 @@ describe('useActionDisabledState', () => { describe('disableResumeCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableResumeCourse).toBe(expected); + expect(runHook().disableResumeCourse).toBe(expected); }; it('disable when resumeUrl is invalid', () => { testDisabled({ resumeUrl: null }, true); @@ -114,7 +117,7 @@ describe('useActionDisabledState', () => { describe('disableViewCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableViewCourse).toBe(expected); + expect(runHook().disableViewCourse).toBe(expected); }; it('disable when hasAccess is false', () => { testDisabled({ hasAccess: false }, true); @@ -129,7 +132,7 @@ describe('useActionDisabledState', () => { describe('disableSelectSession', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableSelectSession).toBe(expected); + expect(runHook().disableSelectSession).toBe(expected); }; it('disable when isEntitlement is false', () => { testDisabled({ isEntitlement: false }, true); @@ -153,6 +156,7 @@ describe('useActionDisabledState', () => { hasAccess: true, canChange: true, hasSessions: true, + availableSessions: ['session1'], }, false, ); @@ -161,7 +165,7 @@ describe('useActionDisabledState', () => { describe('disableCourseTitle', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableCourseTitle).toBe(expected); + expect(runHook().disableCourseTitle).toBe(expected); }; it('disable when isEntitlement is true and isFulfilled is false', () => { testDisabled({ isEntitlement: true, isFulfilled: false }, true); diff --git a/src/containers/CourseCard/hooks.js b/src/containers/CourseCard/hooks.js index b444c9221..76973d5bf 100644 --- a/src/containers/CourseCard/hooks.js +++ b/src/containers/CourseCard/hooks.js @@ -1,23 +1,6 @@ -import { useIntl } from '@openedx/frontend-base'; 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 fb5ccf7ee..9010873c2 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -1,58 +1,32 @@ -import { useIntl } from '@openedx/frontend-base'; +import { renderHook } from '@testing-library/react'; +import { useWindowSize } from '@openedx/paragon'; +import { useIsCollapsed } from './hooks'; -import { reduxHooks } from '@src/hooks'; - -import * as hooks from './hooks'; - -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardEnrollmentData: jest.fn(), +jest.mock('@openedx/paragon', () => ({ + useWindowSize: jest.fn(), + breakpoints: { + small: { + maxWidth: 576, + }, }, })); -jest.mock('@openedx/frontend-base', () => { - const { formatMessage } = jest.requireActual('@src/testUtils'); - return { - ...jest.requireActual('@openedx/frontend-base'), - 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 ae294618f..17f6e6e6a 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 '@openedx/frontend-base'; import { Button, Chip } from '@openedx/paragon'; import { CloseSmall } from '@openedx/paragon/icons'; -import { reduxHooks } from '../../hooks'; +import { useFilters } from '@src/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 f938587b1..d54f14498 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 '@openedx/frontend-base'; import { formatMessage } from '@src/testUtils'; +import { useFilters } from '@src/data/context'; import { FilterKeys } from '@src/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('@src/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(); }); -}); \ No newline at end of file + 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 c93cfed9a..07ef06f2d 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 '@openedx/frontend-base'; - +import track from '@src/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 '@src/data/hooks'; +import { useFilters } from '@src/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 (