Compare commits

..

82 Commits

Author SHA1 Message Date
faa45786f7 Merge pull request 'andal-lnd/fornt-end' (#1) from andal-lnd/fornt-end into master
Reviewed-on: #1
Reviewed-by: Damar <damar@noreply.git.andalsoftware.com>
Reviewed-by: tauficls <tauficls@noreply.git.andalsoftware.com>
2026-04-01 08:40:05 +07:00
Banu
add424ac60 feat: updates for andal-lnd dashboard 2026-03-30 14:47:15 +07:00
renovate[bot]
305960a029 chore(deps): update dependency react-share to v5.3.0 (#807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 05:34:21 +00:00
renovate[bot]
a580d245c0 chore(deps): update dependency @tanstack/react-query to v5.90.21 (#803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 04:54:21 +00:00
Muhammad Arslan
ca954e139d feat: showing course unenroll survey is configurable now (#738) 2026-03-06 09:36:09 -05:00
Jacobo Dominguez
0d2eb96c86 React query and react context conversion (#786)
Migrate from Redux to React Query and React Context.  This modernizes state management while maintaining all existing functionality.  All the redux code and files were removed, including all redux and related packages.
2026-03-03 12:45:57 -03:00
Brian Smith
f1180bffde fix(docs): use correct image for custom course banner (#796) 2026-03-02 14:04:16 +00:00
renovate[bot]
f93598a9e4 chore(deps): update dependency @edx/frontend-platform to v8.5.5 (#798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 09:35:51 +00:00
renovate[bot]
35680b8b05 fix(deps): update dependency core-js to v3.48.0 (#799)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 05:31:49 +00:00
Brian Smith
5d1000530d fix(deps): regenerate package-lock.json (#788) 2026-02-12 10:50:38 -05:00
Maxwell Frank
eef5d3f671 fix: update react-share to v5 (#795) 2026-02-12 09:03:29 -05:00
Maxwell Frank
8fc839dc3d fix: remove unused universal-cookie dep (#794) 2026-02-11 09:45:37 -05:00
Deimer Morales
b21e6e553d fix: include frontend component header translation (#793) 2026-02-10 09:23:17 -05:00
renovate[bot]
cfc82975d8 chore(deps): update dependency @edx/frontend-platform to v8.5.4 (#784)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 06:04:14 +00:00
renovate[bot]
50ea19d6af chore(deps): update dependency lodash to v4.17.23 [security] (#783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 05:47:14 +00:00
renovate[bot]
9e61ae677e chore(deps): update dependency @openedx/paragon to v23.19.1 (#781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 08:33:59 +00:00
renovate[bot]
247794b21d fix(deps): update dependency react-router-dom to v6.30.3 (#780)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 05:34:34 +00:00
renovate[bot]
0a50937b4c chore(deps): update dependency @openedx/paragon to v23.18.2 (#771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-22 05:05:47 +00:00
dependabot[bot]
22a1c658f1 chore(deps): bump actions/checkout from 5 to 6 (#750)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Maxwell Frank <92897870+MaxFrank13@users.noreply.github.com>
2025-12-18 09:00:20 -05:00
Maxwell Frank
75396f1dab fix(deps): remove filesize dependency (#767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 08:55:13 -05:00
Ejaz Ahmad
62099a50eb fix: env variables fetching issue for translations (#766) 2025-12-17 08:47:23 -05:00
renovate[bot]
19ccb8ab87 chore(deps): update dependency @reduxjs/toolkit to v2.11.2 (#761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 06:31:30 +00:00
renovate[bot]
a3e2c80537 chore(deps): update dependency @reduxjs/toolkit to v2.11.1 (#756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 12:40:25 +00:00
renovate[bot]
324cb525c6 fix(deps): update dependency core-js to v3.47.0 (#757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 08:39:38 +00:00
renovate[bot]
f14ab8851d chore(deps): update dependency @openedx/paragon to v23.18.1 (#755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 05:37:57 +00:00
Ejaz Ahmad
c277150716 feat: added the ability for instances to use local translations from extra repositories (#752) 2025-12-03 08:31:14 -05:00
renovate[bot]
2a0ed5714f chore(deps): update dependency @reduxjs/toolkit to v2.11.0 (#749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 09:04:15 +00:00
renovate[bot]
1f0b758705 chore(deps): update dependency @openedx/paragon to v23.18.0 (#748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 05:30:08 +00:00
renovate[bot]
85a5a6e94e chore(deps): update dependency @openedx/paragon to v23.17.0 (#746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 09:13:57 +00:00
renovate[bot]
f59b5013c8 fix(deps): update dependency react-router-dom to v6.30.2 (#745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 06:51:01 +00:00
Maxwell Frank
86b6574c60 [DEPR] feat!: remove notices wrapper (#731) 2025-11-13 12:19:12 -05:00
renovate[bot]
c38e80505c fix(deps): update dependency @edx/frontend-component-header to v8 (#744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 12:57:02 -05:00
renovate[bot]
b926e13c01 chore(deps): update dependency @reduxjs/toolkit to v2.10.1 (#743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 05:33:11 +00:00
renovate[bot]
0a4285aad3 chore(deps): update dependency @openedx/paragon to v23.16.0 (#742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 21:53:16 +00:00
renovate[bot]
e70fa29261 chore(deps): update dependency @reduxjs/toolkit to v2.9.2 (#741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 19:17:47 +00:00
renovate[bot]
b6ba8fb366 chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 08:45:29 +00:00
renovate[bot]
2221655950 chore(deps): update dependency @edx/frontend-component-footer to v14.9.3 (#739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 07:03:55 +00:00
dependabot[bot]
0db621b134 chore(deps): bump actions/setup-node from 5 to 6 (#737) 2025-10-23 13:58:00 -04:00
renovate[bot]
aaf2e36fe9 chore(deps): update dependency @reduxjs/toolkit to v2.9.1 (#736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 15:56:50 +00:00
renovate[bot]
b527fbcfba chore(deps): update dependency @openedx/paragon to v23.14.9 (#735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 05:13:03 +00:00
Feanil Patel
fa3f7b27cf fix: Run npm audit fix to update dependencies. (#734) 2025-10-17 13:23:06 -04:00
Feanil Patel
3b0c58f376 fix: Run npm audit fix to update dependencies. 2025-10-15 10:10:56 -04:00
renovate[bot]
f903392ca1 fix(deps): update dependency core-js to v3.46.0 (#733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 08:39:42 +00:00
renovate[bot]
89032f09f4 chore(deps): update dependency @openedx/paragon to v23.14.8 (#732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:46:26 +00:00
renovate[bot]
03f146cce9 chore(deps): update dependency @testing-library/jest-dom to v6.9.1 (#728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 06:04:32 +00:00
renovate[bot]
7dd2bda86e chore(deps): update dependency @openedx/paragon to v23.14.4 (#724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 02:33:18 -04:00
Feanil Patel
410f2f730f build: remove unused reactifex packages
Remove reactifex and/or @edx/reactifex packages from devDependencies
as they are no longer needed. Translation extraction functionality has
been verified to work correctly without these dependencies.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 14:07:40 -03:00
PKulkoRaccoonGang
de13749443 test: Remove support for Node 20 2025-09-25 09:59:23 -03:00
dependabot[bot]
0e66b2031d chore(deps): bump actions/setup-node from 4 to 5 (#715)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:23:41 -04:00
Peter Kulko
697c9ff2c8 build: Upgrade to Node 24 (#702) 2025-09-24 16:15:55 -04:00
renovate[bot]
fdb5d2f68c fix(deps): update dependency @reduxjs/toolkit to v2.9.0 (#722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 09:47:27 +00:00
renovate[bot]
7cf79f8931 chore(deps): update dependency @testing-library/jest-dom to v6.8.0 (#721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 04:57:59 +00:00
Peter Kulko
73b7b7f5d0 test: Add Node 24 to CI matrix (#701) 2025-09-16 09:36:20 -04:00
renovate[bot]
516544c7cc fix(deps): update dependency @edx/frontend-platform to v8.5.1 (#719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 21:06:02 +00:00
renovate[bot]
2134df5478 fix(deps): update dependency @edx/frontend-component-footer to v14.9.2 (#718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 05:49:50 +00:00
renovate[bot]
5345d2eef2 fix(deps): update dependency @edx/frontend-component-header to v6.6.1 (#714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 10:42:20 +00:00
renovate[bot]
d94ac8dd62 fix(deps): update dependency @edx/frontend-component-footer to v14.9.1 (#713)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 04:33:27 +00:00
Mubbshar Anwar
ff925b06f1 mubbsharanwar/unenrollment process improvement (#704)
Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
2025-09-03 14:39:49 -04:00
Samuel Allan
2696486e5b fix: update frontend-build to fix install issues (#712) 2025-09-02 13:23:36 -04:00
renovate[bot]
5854b00d08 fix(deps): update dependency core-js to v3.45.1 (#711)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 09:46:49 +00:00
renovate[bot]
13e9b1a85f fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.6 (#710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 04:58:44 +00:00
Maxwell Frank
8116956a4b feat: remove widgets in favor of plugins (#708) 2025-08-20 11:35:58 -04:00
Maxwell Frank
8d23e7585b chore: upgrade frontend-component-header 6.6.0 (#709) 2025-08-19 19:43:37 -04:00
Maxwell Frank
b63a40006e feat: add tsconfig.json (#707) 2025-08-19 17:24:18 -04:00
renovate[bot]
92243063b9 fix(deps): update dependency @openedx/paragon to v23.14.2 (#706)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 08:58:22 +00:00
renovate[bot]
a2673399aa chore(deps): update dependency copy-webpack-plugin to v13.0.1 (#705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 05:46:31 +00:00
dependabot[bot]
cedcb4172d chore(deps): bump actions/checkout from 4 to 5 (#700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-14 13:44:21 -04:00
renovate[bot]
9e4faf1569 fix(deps): update dependency @edx/frontend-platform to v8.5.0 (#698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 08:59:39 +00:00
renovate[bot]
3a9acc981b fix(deps): update dependency core-js to v3.45.0 (#699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 05:22:27 +00:00
renovate[bot]
e3c18698d8 fix(deps): update dependency @openedx/paragon to v23.14.1 (#695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 05:45:04 +00:00
Kyle McCormick
eb38beedc3 chore: Delete CODEOWNERS (#691) 2025-07-31 16:08:12 -04:00
renovate[bot]
01faf5a30a fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.3 (#690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 12:15:42 +00:00
renovate[bot]
f4807614e2 chore(deps): update dependency @testing-library/jest-dom to v6.6.4 (#689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 06:51:58 +00:00
renovate[bot]
ab448e52f2 chore(deps): update dependency copy-webpack-plugin to v13 (#688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 12:09:13 -04:00
renovate[bot]
3383016176 fix(deps): update dependency @edx/frontend-component-header to v6.4.2 (#687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 04:43:49 +00:00
renovate[bot]
054cd57d4b fix(deps): update dependency react-router-dom to v6.30.1 (#686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 10:34:49 +00:00
renovate[bot]
0fc3cc4d53 fix(deps): update dependency core-js to v3.44.0 (#685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 06:17:55 +00:00
renovate[bot]
e5c1244e59 fix(deps): update dependency @openedx/paragon to v23.14.0 (#683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 14:10:26 +00:00
renovate[bot]
3a389e14e1 fix(deps): update dependency @edx/frontend-component-header to v6.4.1 (#682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 08:00:29 +00:00
Diana Villalvazo
6de409d7cc test: Deprecate react-unit-test-utils 15/15 (#680) 2025-07-02 12:23:55 -04:00
Diana Villalvazo
517b8424b3 test: Deprecate react-unit-test-utils 13/15 (#678) 2025-07-02 12:18:50 -04:00
Diana Villalvazo
49e527d810 test: Deprecate react-unit-test-utils 14/15 (#672) 2025-07-01 13:16:20 -04:00
236 changed files with 13057 additions and 8966 deletions

3
.env
View File

@@ -1,4 +1,5 @@
NODE_ENV='production' NODE_ENV='production'
APP_ID='learner-dashboard'
NODE_PATH=./src NODE_PATH=./src
BASE_URL='' BASE_URL=''
LMS_BASE_URL='' LMS_BASE_URL=''
@@ -37,10 +38,10 @@ HOTJAR_VERSION='6'
HOTJAR_DEBUG='' HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='' ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL='' ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}

View File

@@ -1,4 +1,5 @@
NODE_ENV='development' NODE_ENV='development'
APP_ID='learner-dashboard'
PORT=1996 PORT=1996
BASE_URL='localhost:1996' BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
@@ -43,10 +44,10 @@ HOTJAR_VERSION='6'
HOTJAR_DEBUG='' HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='http://localhost:1997' ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995' ACCOUNT_PROFILE_URL='http://localhost:1995'
ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}

View File

@@ -1,4 +1,5 @@
NODE_ENV='test' NODE_ENV='test'
APP_ID='learner-dashboard'
PORT=1996 PORT=1996
BASE_URL='localhost:1996' BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
@@ -42,9 +43,9 @@ HOTJAR_VERSION='6'
HOTJAR_DEBUG='' HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='http://account-settings-url.test' ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
ACCOUNT_PROFILE_URL='http://account-profile-url.test' ACCOUNT_PROFILE_URL='http://account-profile-url.test'
ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=true ENABLE_EDX_PERSONAL_DASHBOARD=true
ENABLE_PROGRAMS=false ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @openedx/2U-aperture

View File

@@ -14,10 +14,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Nodejs - name: Setup Nodejs
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -29,6 +29,9 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Type check
run: npm run types
- name: Test - name: Test
run: npm run test run: npm run test

2
.nvmrc
View File

@@ -1 +1 @@
20 24

View File

@@ -12,6 +12,11 @@ transifex_temp = ./temp/babel-plugin-formatjs
NPM_TESTS=build i18n_extract lint test 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 .PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -47,10 +52,12 @@ pull_translations:
&& atlas pull $(ATLAS_OPTIONS) \ && atlas pull $(ATLAS_OPTIONS) \
translations/frontend-platform/src/i18n/messages:frontend-platform \ translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \ translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
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-platform paragon frontend-component-footer frontend-app-learner-dashboard $(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS)
# This target is used by CI. # This target is used by CI.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

View File

@@ -39,22 +39,12 @@ The parts of this MFE that can be customized in that manner are documented `here
Contributing Contributing
------------ ------------
A core goal of this app is to provide a clean experimentation interface. To promote this end, we have provided a Contributions are very welcome. Please read `So you want to contribute to Open edX <https://docs.openedx.org/en/latest/developers/quickstarts/so_you_want_to_contribute.html>`_ for details on how to get started as an Open edX contributor.
silo'ed code directory at ``src/widgets`` in which contributors should add their custom widget components. In order to
ensure our ability to maintain the code stability of the app, the code for these widgets should be strictly contained
within the bounds of that directory.
Once written, the widgets can be configured into one of our widget containers at ``src/containers/WidgetContainers``. This project is currently accepting all types of contributions — bug fixes, security fixes, maintenance work, or new features.
This can include conditional logic, as well as Optimizely triggers. It is important to note that our integration tests However, if you intend to add a new feature, make sure it has gone through the `Product Review process <https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review>`_.
will isolate and ignore these containers, and thus testing your widget is the response of the creator/maintainer of the
widget itself.
Some guidelines for writing widgets: When proposing a change, create an issue in this repo to get the discussion started.
* Code for the widget should be strictly confined to the ``src/widgets`` directory.
* You can load data from the redux store, but should not add or modify fields in that structure.
* Network events should be managed in component hooks, though can use our ``data/constants/requests:requestStates`` for
ease of tracking the request states.
License License
------- -------

View File

@@ -19,6 +19,7 @@ frontend-platform's getConfig loads configuration in the following sequence:
module.exports = { module.exports = {
NODE_ENV: 'development', NODE_ENV: 'development',
APP_ID: 'learner-dashboard',
NODE_PATH: './src', NODE_PATH: './src',
PORT: 1996, PORT: 1996,
BASE_URL: 'localhost:1996', BASE_URL: 'localhost:1996',
@@ -67,7 +68,7 @@ module.exports = {
NEW_RELIC_LICENSE_KEY: '', NEW_RELIC_LICENSE_KEY: '',
ACCOUNT_SETTINGS_URL: 'http://localhost:1997', ACCOUNT_SETTINGS_URL: 'http://localhost:1997',
ACCOUNT_PROFILE_URL: 'http://localhost:1995', ACCOUNT_PROFILE_URL: 'http://localhost:1995',
ENABLE_NOTICES: '',
CAREER_LINK_URL: '', CAREER_LINK_URL: '',
EXPERIMENT_08_23_VAN_PAINTED_DOOR: true, EXPERIMENT_08_23_VAN_PAINTED_DOOR: true,
SHOW_UNENROLL_SURVEY: true
}; };

6084
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,8 @@
"dev": "PUBLIC_PATH=/learner-dashboard/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io", "dev": "PUBLIC_PATH=/learner-dashboard/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests", "test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"quality": "npm run lint-fix && npm run test", "quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch" "watch-tests": "jest --watch",
"types": "tsc --noEmit"
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@@ -30,22 +31,19 @@
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0", "@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0", "@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1", "@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0", "@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5", "@openedx/paragon": "^23.4.5",
"@redux-devtools/extension": "3.3.0", "@tanstack/react-query": "^5.90.16",
"@reduxjs/toolkit": "^2.0.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"core-js": "3.42.0", "core-js": "3.48.0",
"filesize": "^10.0.0",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"history": "5.3.0", "history": "5.3.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -55,32 +53,25 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-intl": "6.8.9", "react-intl": "6.8.9",
"react-redux": "^7.2.4", "react-router-dom": "6.30.3",
"react-router-dom": "6.29.0", "react-share": "^5.2.2",
"react-share": "^4.4.0",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-thunk": "2.4.2",
"regenerator-runtime": "^0.14.0", "regenerator-runtime": "^0.14.0",
"reselect": "^4.0.0",
"universal-cookie": "^4.0.4",
"util": "^0.12.4" "util": "^0.12.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "^1.3.0", "@edx/browserslist-config": "^1.3.0",
"@edx/reactifex": "^2.1.1", "@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.3.3", "@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"copy-webpack-plugin": "^12.0.0", "copy-webpack-plugin": "^13.0.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jest-when": "^3.6.0", "jest-when": "^3.6.0",
"react-dev-utils": "^12.0.0", "react-dev-utils": "^12.0.0",
"react-test-renderer": "^18.3.1", "react-test-renderer": "^18.3.1"
"redux-mock-store": "^1.5.4"
} }
} }

View File

@@ -5,60 +5,29 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage, AppContext } from '@edx/frontend-platform/react'; import { ErrorPage } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon'; import { Alert } from '@openedx/paragon';
import { RequestKeys } from 'data/constants/requests';
import store from 'data/store';
import {
selectors,
actions,
} from 'data/redux';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard'; import Dashboard from 'containers/Dashboard';
import track from 'tracking'; import AppWrapper from 'containers/AppWrapper';
import fakeData from 'data/services/lms/fakeData/courses';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader'; import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { useMasquerade } from 'data/context';
import messages from './messages'; import messages from './messages';
import './App.scss'; import './App.scss';
export const App = () => { export const App = () => {
const { authenticatedUser } = React.useContext(AppContext);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const isFailed = { const { masqueradeUser } = useMasquerade();
initialize: reduxHooks.useRequestIsFailed(RequestKeys.initialize), const { data, isError } = useInitializeLearnerHome();
refreshList: reduxHooks.useRequestIsFailed(RequestKeys.refreshList), const hasNetworkFailure = !masqueradeUser && isError;
}; const supportEmail = data?.platformSettings?.supportEmail || undefined;
const hasNetworkFailure = isFailed.initialize || isFailed.refreshList;
const { supportEmail } = reduxHooks.usePlatformSettingsData();
const loadData = reduxHooks.useLoadData();
/* istanbul ignore next */
React.useEffect(() => { React.useEffect(() => {
if (authenticatedUser?.administrator || getConfig().NODE_ENV === 'development') {
window.loadEmptyData = () => {
loadData({ ...fakeData.globalData, courses: [] });
};
window.loadMockData = () => {
loadData({
...fakeData.globalData,
courses: [
...fakeData.courseRunData,
...fakeData.entitlementData,
],
});
};
window.store = store;
window.selectors = selectors;
window.actions = actions;
window.track = track;
}
if (getConfig().HOTJAR_APP_ID) { if (getConfig().HOTJAR_APP_ID) {
try { try {
initializeHotjar({ initializeHotjar({
@@ -70,7 +39,7 @@ export const App = () => {
logError(error); logError(error);
} }
} }
}, [authenticatedUser, loadData]); }, []);
return ( return (
<> <>
<Helmet> <Helmet>
@@ -91,7 +60,6 @@ export const App = () => {
)} )}
</main> </main>
</AppWrapper> </AppWrapper>
<FooterSlot />
</div> </div>
</> </>
); );

View File

@@ -4,6 +4,85 @@
$fa-font-path: "~font-awesome/fonts"; $fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome"; @import "~font-awesome/scss/font-awesome";
// Andal Learning Brand Colors - Override Paragon variables
:root {
--pgn-color-primary: #ff4f00;
--pgn-color-primary-100: #ffe6cc;
--pgn-color-primary-200: #ffcc99;
--pgn-color-primary-300: #ffb366;
--pgn-color-primary-400: #ff9933;
--pgn-color-primary-500: #ff4f00;
--pgn-color-primary-600: #cc3f00;
--pgn-color-primary-700: #992f00;
--pgn-color-primary-800: #661f00;
--pgn-color-primary-900: #330f00;
--pgn-color-action-primary: #ff4f00;
--pgn-color-action-primary-hover: #cc3f00;
--pgn-color-action-primary-focus: #992f00;
--pgn-color-action-primary-active: #992f00;
}
// Override all button variants to use Andal orange
.btn-primary {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #cc3f00 !important;
border-color: #cc3f00 !important;
}
&:focus {
background-color: #992f00 !important;
border-color: #992f00 !important;
box-shadow: 0 0 0 2px #ffcc99 !important;
}
}
.btn-outline-primary {
color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #ff4f00 !important;
color: #fff !important;
}
&:focus {
box-shadow: 0 0 0 2px #ffcc99 !important;
}
}
.btn-brand {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
color: #fff !important;
&:hover {
background-color: #cc3f00 !important;
border-color: #cc3f00 !important;
}
&:focus {
box-shadow: 0 0 0 2px #ffcc99 !important;
}
}
// Override link colors
a {
color: #ff4f00;
&:hover {
color: #cc3f00;
}
}
// Override active states
.active {
background-color: #ff4f00 !important;
color: #fff !important;
}
$input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work $input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-header/dist/index";

View File

@@ -3,45 +3,43 @@ import { render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { RequestKeys } from 'data/constants/requests'; import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import { App } from './App'; import { App } from './App';
import messages from './messages'; import messages from './messages';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('data/context', () => ({
useMasquerade: jest.fn(() => ({ masqueradeUser: null })),
}));
jest.mock('@edx/frontend-component-footer', () => ({ jest.mock('@edx/frontend-component-footer', () => ({
FooterSlot: jest.fn(() => <div>FooterSlot</div>), FooterSlot: jest.fn(() => <div>FooterSlot</div>),
})); }));
jest.mock('containers/Dashboard', () => jest.fn(() => <div>Dashboard</div>)); jest.mock('containers/Dashboard', () => jest.fn(() => <div>Dashboard</div>));
jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => <div>LearnerDashboardHeader</div>)); jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => <div>LearnerDashboardHeader</div>));
jest.mock('containers/WidgetContainers/AppWrapper', () => jest.fn(({ children }) => <div className="AppWrapper">{children}</div>)); jest.mock('containers/AppWrapper', () => jest.fn(({ children }) => <div className="AppWrapper">{children}</div>));
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
thunkActions: 'redux.thunkActions',
}));
jest.mock('hooks', () => ({
reduxHooks: {
useRequestIsFailed: jest.fn(),
usePlatformSettingsData: jest.fn(),
useLoadData: jest.fn(),
},
}));
jest.mock('data/store', () => 'data/store');
jest.mock('@edx/frontend-platform', () => ({ jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({})), getConfig: jest.fn(() => ({})),
})); }));
// jest.mock('@edx/frontend-platform/react', () => ({ jest.mock('@edx/frontend-platform/react', () => ({
// ...jest.requireActual('@edx/frontend-platform/react'), ...jest.requireActual('@edx/frontend-platform/react'),
// ErrorPage: () => 'ErrorPage', ErrorPage: () => 'ErrorPage',
// })); }));
const loadData = jest.fn();
reduxHooks.useLoadData.mockReturnValue(loadData);
const supportEmail = 'test@support.com'; const supportEmail = 'test@support.com';
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail }); useInitializeLearnerHome.mockReturnValue({
data: {
platformSettings: {
supportEmail,
},
},
isError: false,
});
describe('App router component', () => { describe('App router component', () => {
describe('component', () => { describe('component', () => {
@@ -66,7 +64,6 @@ describe('App router component', () => {
describe('no network failure', () => { describe('no network failure', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({}); getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>); render(<IntlProvider locale="en"><App /></IntlProvider>);
}); });
@@ -79,7 +76,6 @@ describe('App router component', () => {
describe('no network failure with optimizely url', () => { describe('no network failure with optimizely url', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' }); getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render(<IntlProvider locale="en"><App /></IntlProvider>); render(<IntlProvider locale="en"><App /></IntlProvider>);
}); });
@@ -92,7 +88,6 @@ describe('App router component', () => {
describe('no network failure with optimizely project id', () => { describe('no network failure with optimizely project id', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' }); getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render(<IntlProvider locale="en"><App /></IntlProvider>); render(<IntlProvider locale="en"><App /></IntlProvider>);
}); });
@@ -105,7 +100,10 @@ describe('App router component', () => {
describe('initialize failure', () => { describe('initialize failure', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize); useInitializeLearnerHome.mockReturnValue({
data: null,
isError: true,
});
getConfig.mockReturnValue({}); getConfig.mockReturnValue({});
render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>); render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>);
}); });
@@ -119,7 +117,6 @@ describe('App router component', () => {
}); });
describe('refresh failure', () => { describe('refresh failure', () => {
beforeEach(() => { beforeEach(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList);
getConfig.mockReturnValue({}); getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>); render(<IntlProvider locale="en"><App /></IntlProvider>);
}); });

View File

@@ -0,0 +1,17 @@
<svg width="518" height="87" viewBox="0 0 518 87" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512.654 43.4098C512.654 35.747 509.327 31.2325 503.564 31.2325C497.861 31.2325 494.474 35.747 494.474 43.4098C494.474 51.0726 497.861 55.5871 503.564 55.5871C509.327 55.5871 512.654 51.0726 512.654 43.4098ZM518 56.0029C518 67.111 513.188 72.3978 503.029 72.3978C499.286 72.3978 496.019 71.8632 492.87 70.6751V65.5072C496.019 67.1704 499.049 68.0021 502.198 68.0021C509.148 68.0021 512.654 64.3786 512.654 56.9534V54.3397C510.455 58.1414 507.01 60.0422 502.257 60.0422C498.277 60.0422 495.009 58.5572 492.573 55.5277C490.137 52.4982 488.949 48.4589 488.949 43.4098C488.949 38.3607 490.137 34.3807 492.573 31.3513C495.009 28.3218 498.277 26.7773 502.257 26.7773C507.01 26.7773 510.455 28.6782 512.654 32.4799V27.5496H518V56.0029Z" fill="black"/>
<path d="M484.03 50.3004C484.149 55.7653 484.209 59.864 484.209 59.864L478.743 59.9234C478.743 59.9234 478.862 60.7551 478.683 51.0726C478.565 45.4294 478.565 40.6179 478.565 40.6179C478.565 34.4401 476.129 31.4107 471.376 31.4107C465.613 31.4107 462.049 35.3312 462.049 41.6871V60.0422H456.702C456.702 60.0422 456.702 46.7363 456.702 37.1726C456.702 32.5987 453.375 33.0145 453.375 33.0145V27.5496C453.375 27.5496 456.88 27.5496 458.841 29.688C460.564 31.5889 460.623 33.7273 460.623 33.7273C461.811 29.7474 466.802 26.7773 472.03 26.7773C479.456 26.7773 483.911 31.4701 483.911 40.4397C483.911 40.4397 483.911 41.5089 484.03 50.3004Z" fill="black"/>
<path d="M443.611 27.5485H448.958C448.958 27.5485 448.661 42.8147 448.779 51.4873C448.779 55.5861 452.166 55.1702 452.166 55.1702V60.2194C452.166 60.2194 443.492 60.0412 443.611 52.2002C443.73 42.6365 443.611 27.5485 443.611 27.5485ZM446.225 14.3613C444.264 14.3613 442.66 15.9652 442.66 17.9254C442.66 19.8857 444.264 21.4895 446.225 21.4895C448.185 21.4895 449.789 19.9451 449.789 17.9254C449.789 15.9058 448.185 14.3613 446.225 14.3613Z" fill="black"/>
<path d="M436.376 50.3004C436.495 55.7653 436.554 59.864 436.554 59.864L431.089 59.9234C431.089 59.9234 431.207 60.7551 431.029 51.0726C430.91 45.4294 430.91 40.6179 430.91 40.6179C430.91 34.4401 428.475 31.4107 423.722 31.4107C417.959 31.4107 414.395 35.3312 414.395 41.6871V60.0422H409.048C409.048 60.0422 409.048 46.7363 409.048 37.1726C409.048 32.5987 405.721 33.0145 405.721 33.0145V27.5496C405.721 27.5496 409.226 27.5496 411.186 29.688C412.909 31.5889 412.969 33.7273 412.969 33.7273C414.157 29.7474 419.147 26.7773 424.375 26.7773C431.801 26.7773 436.257 31.4701 436.257 40.4397C436.257 40.4397 436.257 41.5089 436.376 50.3004Z" fill="black"/>
<path d="M403.931 32.4208C402.208 32.5396 400.961 32.5396 399 33.49C396.386 34.7969 393.237 37.7076 393.237 42.9349C393.237 49.8849 393.237 60.0426 393.237 60.0426H387.891V27.5499H393.237V32.599C395.257 29.5695 397.337 27.3717 401.911 27.1935C402.684 27.1341 402.446 27.1341 403.931 27.1935V32.4208Z" fill="black"/>
<path d="M369.696 43.7068C365.359 43.7068 362.388 44.182 360.725 45.1918C359.061 46.2017 358.23 47.8649 358.23 50.241C358.23 54.0427 360.903 56.4187 365.181 56.4187C371.122 56.4187 374.983 51.8448 374.983 44.8948V43.7068H369.696ZM380.271 51.9636C380.271 56.1811 384.251 55.8247 384.251 55.8247V59.9234C384.251 59.9234 381.221 60.2799 379.142 58.6166C377.241 57.1316 376.112 54.9337 376.112 54.9337C373.676 58.9136 369.042 60.8739 363.755 60.8739C357.101 60.8739 352.883 56.8346 352.883 50.5974C352.883 43.291 357.814 39.5487 367.498 39.5487H374.983V39.0141C374.983 34.0837 372.191 31.2919 366.309 31.2919C363.577 31.2919 360.071 33.3709 360.19 36.935L355.259 37.0538C355.378 28.6782 362.923 26.7773 366.428 26.7773C375.577 26.7773 380.33 31.5889 380.33 41.5089C380.33 41.5089 380.211 47.8055 380.271 51.9636Z" fill="black"/>
<path d="M349.423 42.4594V45.073H324.887C325.362 52.4388 329.402 56.3593 336.472 56.3593C340.571 56.3593 344.374 55.4089 348.176 53.3299V58.379C344.374 60.0422 340.274 60.8739 336.175 60.8739C331.006 60.8739 326.848 59.3888 323.818 56.3593C320.788 53.3299 319.303 49.2906 319.303 44.1226C319.303 38.8359 320.729 34.6184 323.58 31.4701C326.491 28.3218 330.353 26.7773 335.224 26.7773C339.621 26.7773 343.067 28.203 345.621 30.9949C348.176 33.7867 349.423 37.6478 349.423 42.4594ZM344.076 40.9149C344.017 35.0342 340.571 31.2919 335.284 31.2919C329.343 31.2919 325.6 34.7966 325.065 40.9149H344.076Z" fill="black"/>
<path d="M308.35 14.897H313.696C313.696 14.897 313.875 38.4794 313.875 51.1914C313.875 55.4089 319.459 55.3495 319.459 55.3495L319.4 60.161C319.4 60.161 313.578 60.3392 310.488 56.0029C309.003 53.9832 308.35 51.726 308.35 48.7559C308.35 35.3905 308.35 14.897 308.35 14.897Z" fill="black"/>
<path d="M279.439 14.897H284.786C284.786 14.897 284.965 38.4794 284.965 51.1914C284.965 55.4089 290.549 55.3495 290.549 55.3495L290.49 60.161C290.49 60.161 284.667 60.3392 281.578 56.0029C280.093 53.9832 279.439 51.726 279.439 48.7559C279.439 35.3905 279.439 14.897 279.439 14.897Z" fill="black"/>
<path d="M261.065 43.7068C256.728 43.7068 253.757 44.182 252.094 45.1918C250.431 46.2017 249.599 47.8649 249.599 50.241C249.599 54.0427 252.272 56.4187 256.55 56.4187C262.491 56.4187 266.352 51.8448 266.352 44.8948V43.7068H261.065ZM271.64 51.9636C271.64 56.1811 275.62 55.8247 275.62 55.8247V59.9234C275.62 59.9234 272.59 60.2799 270.511 58.6166C268.61 57.1316 267.481 54.9337 267.481 54.9337C265.045 58.9136 260.411 60.8739 255.124 60.8739C248.47 60.8739 244.252 56.8346 244.252 50.5974C244.252 43.291 249.183 39.5487 258.867 39.5487H266.352V39.0141C266.352 34.0837 263.56 31.2919 257.678 31.2919C254.946 31.2919 251.44 33.3709 251.559 36.935L246.628 37.0538C246.747 28.6782 254.292 26.7773 257.797 26.7773C266.946 26.7773 271.699 31.5889 271.699 41.5089C271.699 41.5089 271.58 47.8055 271.64 51.9636Z" fill="black"/>
<path d="M233.216 32.4799V14.897H238.563C238.563 14.897 238.682 38.42 238.682 50.8943C238.682 55.2901 238.682 59.864 238.682 59.864L233.81 59.9234L233.87 55.4089C231.671 59.27 227.572 60.8738 222.819 60.8738C218.898 60.8738 215.69 59.3294 213.195 56.1811C210.759 53.0328 209.512 48.9341 209.512 43.8256C209.512 38.717 210.759 34.6183 213.195 31.47C215.69 28.3217 218.898 26.7773 222.819 26.7773C227.572 26.7773 231.018 28.6187 233.216 32.4799ZM215.037 43.8256C215.037 51.6666 218.423 56.4187 224.126 56.4187C229.77 56.4187 233.216 51.6666 233.216 43.8256C233.216 35.9845 229.77 31.2324 224.126 31.2324C218.423 31.2324 215.037 35.9845 215.037 43.8256Z" fill="black"/>
<path d="M204.591 50.3004C204.71 55.7653 204.769 59.864 204.769 59.864L199.303 59.9234C199.303 59.9234 199.422 60.7551 199.244 51.0726C199.125 45.4294 199.125 40.6179 199.125 40.6179C199.125 34.4401 196.689 31.4107 191.937 31.4107C186.174 31.4107 182.609 35.3312 182.609 41.6871V60.0422H177.262C177.262 60.0422 177.262 46.7363 177.262 37.1726C177.262 32.5987 173.936 33.0145 173.936 33.0145V27.5496C173.936 27.5496 177.441 27.5496 179.401 29.688C181.124 31.5889 181.183 33.7273 181.183 33.7273C182.372 29.7474 187.362 26.7773 192.59 26.7773C200.016 26.7773 204.472 31.4701 204.472 40.4397C204.472 40.4397 204.472 41.5089 204.591 50.3004Z" fill="black"/>
<path d="M159.067 43.7068C154.73 43.7068 151.759 44.182 150.096 45.1918C148.432 46.2017 147.601 47.8649 147.601 50.241C147.601 54.0427 150.274 56.4187 154.552 56.4187C160.493 56.4187 164.354 51.8448 164.354 44.8948V43.7068H159.067ZM169.642 51.9636C169.642 56.1811 173.622 55.8247 173.622 55.8247V59.9234C173.622 59.9234 170.592 60.2799 168.513 58.6166C166.612 57.1316 165.483 54.9337 165.483 54.9337C163.047 58.9136 158.413 60.8739 153.126 60.8739C146.472 60.8739 142.254 56.8346 142.254 50.5974C142.254 43.291 147.185 39.5487 156.869 39.5487H164.354V39.0141C164.354 34.0837 161.562 31.2919 155.68 31.2919C152.948 31.2919 149.442 33.3709 149.561 36.935L144.63 37.0538C144.749 28.6782 152.294 26.7773 155.799 26.7773C164.948 26.7773 169.701 31.5889 169.701 41.5089C169.701 41.5089 169.582 47.8055 169.642 51.9636Z" fill="black"/>
<path d="M8.40549 86.8917C3.76365 86.8917 0 83.0908 0 78.4008V55.9119C0 51.2229 3.76265 47.421 8.40549 47.421H121.955C121.91 52.4182 121.875 57.4144 121.851 62.4106C121.821 68.5717 121.808 74.7338 121.81 80.8959C124.658 80.8726 127.506 80.8482 130.355 80.8249V86.8907L8.40549 86.8917Z" fill="#FF4F00"/>
<path d="M124.912 40.1348H9.02975C4.08481 40.1348 0.0752715 36.3339 0.0752715 31.6439V8.49088C0.0752715 3.80189 4.08481 0 9.02975 0H130.282C130.264 1.47615 130.246 2.95331 130.229 4.42945C129.018 4.43249 127.808 4.43554 126.596 4.43858C125.745 8.73218 124.895 13.0268 124.045 17.3204C122.613 23.2077 121.182 29.0951 119.749 34.9814H124.935C124.927 36.6989 124.919 38.4173 124.912 40.1348Z" fill="#FF4F00"/>
</svg>

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,9 +1,6 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import Banner from './Banner'; import Banner from './Banner';
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('Banner component', () => { describe('Banner component', () => {
it('renders children content', () => { it('renders children content', () => {
render(<Banner>Test content</Banner>); render(<Banner>Test content</Banner>);

View File

@@ -1,25 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError, logInfo } from '@edx/frontend-platform/logging';
export const noticesUrl = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`;
export const getNotices = ({ onLoad, notFoundMessage }) => {
const authenticatedUser = getAuthenticatedUser();
const handleError = async (e) => {
// Error probably means that notices is not installed, which is fine.
const { customAttributes: { httpErrorStatus } } = e;
if (httpErrorStatus === 404) {
logInfo(`${e}. ${notFoundMessage}`);
} else {
logError(e);
}
};
if (authenticatedUser) {
return getAuthenticatedHttpClient().get(noticesUrl, {}).then(onLoad).catch(handleError);
}
return null;
};
export default { getNotices };

View File

@@ -1,65 +0,0 @@
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import * as api from './api';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({
LMS_BASE_URL: 'test-lms-url',
})),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
getAuthenticatedUser: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
}));
const testData = 'test-data';
const successfulGet = () => Promise.resolve(testData);
const error404 = { customAttributes: { httpErrorStatus: 404 }, test: 'error' };
const error404Get = () => Promise.reject(error404);
const error500 = { customAttributes: { httpErrorStatus: 500 }, test: 'error' };
const error500Get = () => Promise.reject(error500);
const get = jest.fn().mockImplementation(successfulGet);
getAuthenticatedHttpClient.mockReturnValue({ get });
const authenticatedUser = { fake: 'user' };
getAuthenticatedUser.mockReturnValue(authenticatedUser);
const onLoad = jest.fn();
describe('getNotices api method', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
describe('not authenticated', () => {
it('does not fetch anything', () => {
getAuthenticatedUser.mockReturnValueOnce(null);
api.getNotices({ onLoad });
expect(get).not.toHaveBeenCalled();
});
});
describe('authenticated', () => {
it('fetches noticesUrl with onLoad behavior', async () => {
await api.getNotices({ onLoad });
expect(get).toHaveBeenCalledWith(api.noticesUrl, {});
expect(onLoad).toHaveBeenCalledWith(testData);
});
it('calls logInfo if fetch fails with 404', async () => {
get.mockImplementation(error404Get);
await api.getNotices({ onLoad });
expect(logInfo).toHaveBeenCalledWith(`${error404}. ${api.error404Message}`);
});
it('calls logError if fetch fails with non-404 error', async () => {
get.mockImplementation(error500Get);
await api.getNotices({ onLoad });
expect(logError).toHaveBeenCalledWith(error500);
});
});
});
});

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { getNotices } from './api';
import * as module from './hooks';
import messages from './messages';
/**
* This component uses the platform-plugin-notices plugin to function.
* If the user has an unacknowledged notice, they will be rerouted off
* course home and onto a full-screen notice page. If the plugin is not
* installed, or there are no notices, we just passthrough this component.
*/
export const state = StrictDict({
isRedirected: (val) => React.useState(val), // eslint-disable-line
});
export const useNoticesWrapperData = () => {
const [isRedirected, setIsRedirected] = module.state.isRedirected();
const { formatMessage } = useIntl();
React.useEffect(() => {
if (getConfig().ENABLE_NOTICES) {
getNotices({
onLoad: (data) => {
if (data?.data?.results?.length > 0) {
setIsRedirected(true);
window.location.replace(`${data.data.results[0]}?next=${window.location.href}`);
}
},
notFoundMessage: formatMessage(messages.error404Message),
});
}
}, [setIsRedirected, formatMessage]);
return { isRedirected };
};
export default useNoticesWrapperData;

View File

@@ -1,99 +0,0 @@
import React from 'react';
import { MockUseState, formatMessage } from 'testUtils';
import { getConfig } from '@edx/frontend-platform';
import { getNotices } from './api';
import * as hooks from './hooks';
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
jest.mock('./api', () => ({ getNotices: jest.fn() }));
// jest.mock('react', () => ({
// ...jest.requireActual('react'),
// useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
// useContext: jest.fn(context => context),
// }));
// jest.mock('@edx/frontend-platform/i18n', () => {
// const { formatMessage: fn } = jest.requireActual('testUtils');
// return {
// ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({
// formatMessage: fn,
// }),
// };
// });
getConfig.mockReturnValue({ ENABLE_NOTICES: true });
const state = new MockUseState(hooks);
let hook;
describe('NoticesWrapper hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.isRedirected);
});
describe('useNoticesWrapperData', () => {
beforeEach(() => {
state.mock();
});
describe('behavior', () => {
it('initializes state hooks', () => {
hooks.useNoticesWrapperData();
expect(hooks.state.isRedirected).toHaveBeenCalledWith();
});
describe('effects', () => {
it('does not call notices if not enabled', () => {
getConfig.mockReturnValueOnce({ ENABLE_NOTICES: false });
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
cb();
expect(getNotices).not.toHaveBeenCalled();
});
describe('getNotices call (if enabled) onLoad behavior', () => {
it('does not redirect if there are no results', () => {
hooks.useNoticesWrapperData();
expect(React.useEffect).toHaveBeenCalled();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];
onLoad({});
expect(state.setState.isRedirected).not.toHaveBeenCalled();
onLoad({ data: {} });
expect(state.setState.isRedirected).not.toHaveBeenCalled();
onLoad({ data: { results: [] } });
expect(state.setState.isRedirected).not.toHaveBeenCalled();
});
it('redirects and set isRedirected if results are returned', () => {
delete window.location;
window.location = { replace: jest.fn(), href: 'test-old-href' };
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];
const target = 'url-target';
onLoad({ data: { results: [target] } });
expect(state.setState.isRedirected).toHaveBeenCalledWith(true);
expect(window.location.replace).toHaveBeenCalledWith(
`${target}?next=${window.location.href}`,
);
});
});
});
});
describe('output', () => {
it('forwards isRedirected from state call', () => {
hook = hooks.useNoticesWrapperData();
expect(hook.isRedirected).toEqual(state.stateVals.isRedirected);
});
});
});
});

View File

@@ -1,25 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import useNoticesWrapperData from './hooks';
/**
* This component uses the platform-plugin-notices plugin to function.
* If the user has an unacknowledged notice, they will be rerouted off
* course home and onto a full-screen notice page. If the plugin is not
* installed, or there are no notices, we just passthrough this component.
*/
const NoticesWrapper = ({ children }) => {
const { isRedirected } = useNoticesWrapperData();
return (
<div>
{isRedirected === true ? null : children}
</div>
);
};
NoticesWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
export default NoticesWrapper;

View File

@@ -1,36 +0,0 @@
import { render, screen } from '@testing-library/react';
import useNoticesWrapperData from './hooks';
import NoticesWrapper from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = { isRedirected: false };
const children = [<b key={1}>some</b>, <i key={2}>children</i>];
describe('NoticesWrapper component', () => {
beforeEach(() => {
useNoticesWrapperData.mockClear();
});
describe('behavior', () => {
it('initializes hooks', () => {
useNoticesWrapperData.mockReturnValue(hookProps);
render(<NoticesWrapper>{children}</NoticesWrapper>);
expect(useNoticesWrapperData).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('does not show children if redirected', () => {
useNoticesWrapperData.mockReturnValueOnce({ isRedirected: true });
render(<NoticesWrapper>{children}</NoticesWrapper>);
expect(screen.queryByText('some')).not.toBeInTheDocument();
expect(screen.queryByText('children')).not.toBeInTheDocument();
});
it('shows children if not redirected', () => {
useNoticesWrapperData.mockReturnValue(hookProps);
render(<NoticesWrapper>{children}</NoticesWrapper>);
expect(screen.getByText('some')).toBeInTheDocument();
expect(screen.getByText('children')).toBeInTheDocument();
});
});
});

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error404Message: {
id: 'learner-dash.notices.error404Message',
defaultMessage: 'This probably happened because the notices plugin is not installed on platform.',
description: 'Error message when notices API returns 404',
},
});
export default messages;

View File

@@ -1,5 +1,6 @@
const configuration = { const configuration = {
// BASE_URL: process.env.BASE_URL, // BASE_URL: process.env.BASE_URL,
APP_ID: process.env.APP_ID,
LMS_BASE_URL: process.env.LMS_BASE_URL, LMS_BASE_URL: process.env.LMS_BASE_URL,
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL, ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL,
CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL, CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL,
@@ -14,13 +15,13 @@ const configuration = {
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '', SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
SUPPORT_URL: process.env.SUPPORT_URL || null, SUPPORT_URL: process.env.SUPPORT_URL || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null, CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
LOGO_URL: process.env.LOGO_URL, LOGO_URL: process.env.LOGO_URL,
ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true', ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true',
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null, SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true', ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true', NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
SHOW_UNENROLL_SURVEY: process.env.SHOW_UNENROLL_SURVEY === 'true',
}; };
const features = {}; const features = {};

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AppWrapper from './index';
describe('AppWrapper', () => {
it('should render children without modification', () => {
render(
<AppWrapper>
<div>Test Child</div>
</AppWrapper>,
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
});
});

View File

@@ -1,32 +1,32 @@
import { useWindowSize, breakpoints } from '@openedx/paragon'; import { useWindowSize, breakpoints } from '@openedx/paragon';
import useIsCollapsed from './hooks'; import useIsCollapsed from './hooks';
// jest.mock('@openedx/paragon', () => ({ jest.mock('@openedx/paragon', () => ({
// ...jest.requireActual('@openedx/paragon'), ...jest.requireActual('@openedx/paragon'),
// useWindowSize: jest.fn(), useWindowSize: jest.fn(),
// breakpoints: { breakpoints: {
// extraSmall: { extraSmall: {
// minWidth: 0, minWidth: 0,
// maxWidth: 575, maxWidth: 575,
// }, },
// small: { small: {
// minWidth: 576, minWidth: 576,
// maxWidth: 767, maxWidth: 767,
// }, },
// medium: { medium: {
// minWidth: 768, minWidth: 768,
// maxWidth: 991, maxWidth: 991,
// }, },
// large: { large: {
// minWidth: 992, minWidth: 992,
// maxWidth: 1199, maxWidth: 1199,
// }, },
// extraLarge: { extraLarge: {
// minWidth: 1200, minWidth: 1200,
// maxWidth: 100000, maxWidth: 100000,
// }, },
// }, },
// })); }));
describe('useIsCollapsed', () => { describe('useIsCollapsed', () => {
it('returns true only when it is between medium and small', () => { it('returns true only when it is between medium and small', () => {

View File

@@ -5,8 +5,6 @@ import useIsCollapsed from './hooks';
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
jest.unmock('@openedx/paragon');
describe('ActionButton', () => { describe('ActionButton', () => {
const props = { const props = {
className: 'custom-class', className: 'custom-class',

View File

@@ -1,21 +1,29 @@
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import messages from './messages'; import messages from './messages';
export const BeginCourseButton = ({ cardId }) => { export const BeginCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); const { data: learnerData } = useInitializeLearnerHome();
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); 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 { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent( const handleClick = useCourseTrackingEvent(
track.course.enterCourseClicked, track.course.enterCourseClicked,
cardId, cardId,
homeUrl + execEdTrackingParam, homeUrl + execEdTrackingParam,

View File

@@ -1,36 +1,42 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import BeginCourseButton from './BeginCourseButton'; import BeginCourseButton from './BeginCourseButton';
jest.mock('hooks', () => ({
useCourseData: jest.fn().mockReturnValue({
enrollment: { mode: 'executive-education' },
courseRun: { homeUrl: 'home-url' },
}),
useCourseTrackingEvent: jest.fn().mockReturnValue({
trackCourseEvent: jest.fn(),
}),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({
data: {
enterpriseDashboard: {
authOrgId: 'test-org-id',
},
},
}),
}));
jest.mock('tracking', () => ({ jest.mock('tracking', () => ({
course: { course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
}, },
})); }));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardExecEdTrackingParam: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
const homeUrl = 'home-url'; 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 = { const props = {
cardId: 'cardId', cardId: 'cardId',
@@ -45,11 +51,7 @@ describe('BeginCourseButton', () => {
describe('initiliaze hooks', () => { describe('initiliaze hooks', () => {
it('initializes course run data with cardId', () => { it('initializes course run data with cardId', () => {
renderComponent(); renderComponent();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); expect(useCourseData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
renderComponent();
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
}); });
it('loads disabled states for begin action from action hooks', () => { it('loads disabled states for begin action from action hooks', () => {
renderComponent(); renderComponent();
@@ -73,15 +75,15 @@ describe('BeginCourseButton', () => {
expect(button).not.toHaveClass('disabled'); expect(button).not.toHaveClass('disabled');
expect(button).not.toHaveAttribute('aria-disabled', 'true'); 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(); renderComponent();
const user = userEvent.setup(); const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Begin Course' }); const button = screen.getByRole('button', { name: 'Begin Course' });
user.click(button); user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked, track.course.enterCourseClicked,
props.cardId, props.cardId,
homeUrl + execEdPath(props.cardId), `${homeUrl}?org_id=test-org-id`,
); );
}); });
}); });

View File

@@ -1,21 +1,29 @@
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseTrackingEvent, useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import messages from './messages'; import messages from './messages';
export const ResumeButton = ({ cardId }) => { export const ResumeButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId); const { data: learnerData } = useInitializeLearnerHome();
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); 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 { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent( const handleClick = useCourseTrackingEvent(
track.course.enterCourseClicked, track.course.enterCourseClicked,
cardId, cardId,
resumeUrl + execEdTrackingParam, resumeUrl + execEdTrackingParam,

View File

@@ -1,36 +1,47 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseTrackingEvent, useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ResumeButton from './ResumeButton'; import ResumeButton from './ResumeButton';
const authOrgId = 'auth-org-id';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({
data: {
enterpriseDashboard: {
authOrgId,
},
},
}),
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn().mockReturnValue({
enrollment: { mode: 'executive-education' },
courseRun: { homeUrl: 'home-url' },
}),
useCourseTrackingEvent: jest.fn().mockReturnValue({
trackCourseEvent: jest.fn(),
}),
}));
jest.mock('tracking', () => ({ jest.mock('tracking', () => ({
course: { course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
}, },
})); }));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardExecEdTrackingParam: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
const resumeUrl = 'resume-url'; useCourseData.mockReturnValue({
reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl }); enrollment: { mode: 'executive-education' },
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`; courseRun: { resumeUrl: 'home-url' },
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath); });
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
);
describe('ResumeButton', () => { describe('ResumeButton', () => {
const props = { const props = {
@@ -39,10 +50,7 @@ describe('ResumeButton', () => {
describe('initialize hooks', () => { describe('initialize hooks', () => {
beforeEach(() => render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>)); beforeEach(() => render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>));
it('initializes course run data with cardId', () => { it('initializes course run data with cardId', () => {
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); expect(useCourseData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
}); });
it('loads disabled states for resume action from action hooks', () => { it('loads disabled states for resume action from action hooks', () => {
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
@@ -73,10 +81,10 @@ describe('ResumeButton', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Resume' }); const button = screen.getByRole('button', { name: 'Resume' });
user.click(button); user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked, track.course.enterCourseClicked,
props.cardId, props.cardId,
resumeUrl + execEdPath(props.cardId), `home-url?org_id=${authOrgId}`,
); );
}); });
}); });

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useSelectSessionModal } from 'data/context';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import messages from './messages'; import messages from './messages';
@@ -11,11 +11,11 @@ import messages from './messages';
export const SelectSessionButton = ({ cardId }) => { export const SelectSessionButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { disableSelectSession } = useActionDisabledState(cardId); const { disableSelectSession } = useActionDisabledState(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); const { updateSelectSessionModal } = useSelectSessionModal();
return ( return (
<ActionButton <ActionButton
disabled={disableSelectSession} disabled={disableSelectSession}
onClick={openSessionModal} onClick={() => updateSelectSessionModal(cardId)}
> >
{formatMessage(messages.selectSession)} {formatMessage(messages.selectSession)}
</ActionButton> </ActionButton>

View File

@@ -1,16 +1,16 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelectSessionModal } from 'data/context';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import SelectSessionButton from './SelectSessionButton'; import SelectSessionButton from './SelectSessionButton';
jest.mock('hooks', () => ({ jest.mock('data/context', () => ({
reduxHooks: { useSelectSessionModal: jest.fn().mockReturnValue({
useUpdateSelectSessionModalCallback: jest.fn(), updateSelectSessionModal: jest.fn(),
}, }),
})); }));
jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false })));
@@ -33,11 +33,15 @@ describe('SelectSessionButton', () => {
}); });
describe('on click', () => { describe('on click', () => {
it('should call openSessionModal', async () => { it('should call openSessionModal', async () => {
const mockedUpdateSelectSessionModal = jest.fn();
useSelectSessionModal.mockReturnValue({
updateSelectSessionModal: mockedUpdateSelectSessionModal,
});
render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>); render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>);
const user = userEvent.setup(); const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Select Session' }); const button = screen.getByRole('button', { name: 'Select Session' });
await user.click(button); await user.click(button);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId); expect(mockedUpdateSelectSessionModal).toHaveBeenCalledWith(props.cardId);
}); });
}); });
}); });

View File

@@ -4,17 +4,18 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseTrackingEvent, useCourseData } from 'hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import messages from './messages'; import messages from './messages';
export const ViewCourseButton = ({ cardId }) => { export const ViewCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); const courseData = useCourseData(cardId);
const homeUrl = courseData?.courseRun?.homeUrl;
const { disableViewCourse } = useActionDisabledState(cardId); const { disableViewCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent( const handleClick = useCourseTrackingEvent(
track.course.enterCourseClicked, track.course.enterCourseClicked,
cardId, cardId,
homeUrl, homeUrl,

View File

@@ -1,24 +1,27 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseTrackingEvent } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ViewCourseButton from './ViewCourseButton'; import ViewCourseButton from './ViewCourseButton';
jest.mock('hooks', () => ({
useCourseData: jest.fn().mockReturnValue({
courseRun: { homeUrl: 'homeUrl' },
}),
useCourseTrackingEvent: jest.fn().mockReturnValue({
trackCourseEvent: jest.fn(),
}),
}));
jest.mock('tracking', () => ({ jest.mock('tracking', () => ({
course: { course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
}, },
})); }));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false })));
jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
@@ -35,15 +38,18 @@ describe('ViewCourseButton', () => {
expect(button).not.toHaveAttribute('aria-disabled', 'true'); expect(button).not.toHaveAttribute('aria-disabled', 'true');
}); });
it('calls trackCourseEvent on click', async () => { it('calls trackCourseEvent on click', async () => {
const mockedTrackCourseEvent = jest.fn();
useCourseTrackingEvent.mockReturnValue(mockedTrackCourseEvent);
render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>); render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>);
const user = userEvent.setup(); const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'View Course' }); const button = screen.getByRole('button', { name: 'View Course' });
await user.click(button); await user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked, track.course.enterCourseClicked,
defaultProps.cardId, defaultProps.cardId,
homeUrl, homeUrl,
); );
expect(mockedTrackCourseEvent).toHaveBeenCalled();
}); });
it('learner cannot view course', () => { it('learner cannot view course', () => {
useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true }); useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true });

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { ActionRow } from '@openedx/paragon'; import { ActionRow } from '@openedx/paragon';
import { reduxHooks } from 'hooks'; import { useCourseData, useEntitlementInfo } from 'hooks';
import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot'; import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot';
import SelectSessionButton from './SelectSessionButton'; import SelectSessionButton from './SelectSessionButton';
@@ -12,11 +12,10 @@ import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton'; import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => { export const CourseCardActions = ({ cardId }) => {
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId); const cardData = useCourseData(cardId);
const { const hasStarted = cardData.enrollment.hasStarted || false;
hasStarted, const { isEntitlement, isFulfilled } = useEntitlementInfo(cardData);
} = reduxHooks.useCardEnrollmentData(cardId); const isArchived = cardData.courseRun.isArchived || false;
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
return ( return (
<ActionRow data-test-id="CourseCardActions"> <ActionRow data-test-id="CourseCardActions">

View File

@@ -1,15 +1,10 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import CourseCardActions from '.'; import CourseCardActions from '.';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { ...jest.requireActual('hooks'),
useCardCourseRunData: jest.fn(), useCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useMasqueradeData: jest.fn(),
},
})); }));
jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() => <div>CourseCardActionSlot</div>)); jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() => <div>CourseCardActionSlot</div>));
@@ -18,34 +13,28 @@ jest.mock('./ViewCourseButton', () => jest.fn(() => <div>ViewCourseButton</div>)
jest.mock('./BeginCourseButton', () => jest.fn(() => <div>BeginCourseButton</div>)); jest.mock('./BeginCourseButton', () => jest.fn(() => <div>BeginCourseButton</div>));
jest.mock('./ResumeButton', () => jest.fn(() => <div>ResumeButton</div>)); jest.mock('./ResumeButton', () => jest.fn(() => <div>ResumeButton</div>));
jest.unmock('@openedx/paragon');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const props = { cardId }; const props = { cardId };
describe('CourseCardActions', () => { describe('CourseCardActions', () => {
const mockHooks = ({ const mockHooks = ({
isEntitlement = false, isEntitlement = false,
isExecEd2UCourse = false,
isFulfilled = false, isFulfilled = false,
isArchived = false, isArchived = false,
isVerified = false,
hasStarted = false, hasStarted = false,
isMasquerading = false,
} = {}) => { } = {}) => {
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled }); useCourseData.mockReturnValueOnce({
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived }); enrollment: { hasStarted },
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted }); courseRun: { isArchived },
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); entitlement: isEntitlement !== null ? { isEntitlement, isFulfilled } : null,
});
}; };
const renderComponent = () => render(<CourseCardActions {...props} />); const renderComponent = () => render(<CourseCardActions {...props} />);
describe('hooks', () => { describe('hooks', () => {
it('initializes redux hooks', () => { it('initializes hooks', () => {
mockHooks(); mockHooks();
renderComponent(); renderComponent();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('output', () => { describe('output', () => {
@@ -65,7 +54,7 @@ describe('CourseCardActions', () => {
}); });
describe('not entitlement, verified, or exec ed', () => { describe('not entitlement, verified, or exec ed', () => {
it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => { it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => {
mockHooks({ isArchived: true }); mockHooks({ isArchived: true, isEntitlement: null });
renderComponent(); renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument(); expect(CourseCardActionSlot).toBeInTheDocument();
@@ -74,7 +63,7 @@ describe('CourseCardActions', () => {
}); });
describe('unstarted courses', () => { describe('unstarted courses', () => {
it('renders CourseCardActionSlot and BeginCourseButton', () => { it('renders CourseCardActionSlot and BeginCourseButton', () => {
mockHooks(); mockHooks({ isEntitlement: null });
renderComponent(); renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument(); expect(CourseCardActionSlot).toBeInTheDocument();
@@ -84,7 +73,7 @@ describe('CourseCardActions', () => {
}); });
describe('active courses (started, and not archived)', () => { describe('active courses (started, and not archived)', () => {
it('renders CourseCardActionSlot and ResumeButton', () => { it('renders CourseCardActionSlot and ResumeButton', () => {
mockHooks({ hasStarted: true }); mockHooks({ hasStarted: true, isEntitlement: null });
renderComponent(); renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument(); expect(CourseCardActionSlot).toBeInTheDocument();

View File

@@ -1,12 +1,14 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { MailtoLink, Hyperlink } from '@openedx/paragon'; import { MailtoLink, Hyperlink } from '@openedx/paragon';
import { CheckCircle } from '@openedx/paragon/icons'; import { CheckCircle } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { baseAppUrl } from 'data/services/lms/urls';
import { utilHooks, reduxHooks } from 'hooks'; import { useInitializeLearnerHome } from 'data/hooks';
import { utilHooks, useCourseData } from 'hooks';
import Banner from 'components/Banner'; import Banner from 'components/Banner';
import messages from './messages'; import messages from './messages';
@@ -14,15 +16,32 @@ import messages from './messages';
const { useFormatDate } = utilHooks; const { useFormatDate } = utilHooks;
export const CertificateBanner = ({ cardId }) => { export const CertificateBanner = ({ cardId }) => {
const certificate = reduxHooks.useCardCertificateData(cardId); const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const { const {
isAudit, certificate = {},
isVerified, isVerified = false,
} = reduxHooks.useCardEnrollmentData(cardId); isAudit = false,
const { isPassing } = reduxHooks.useCardGradeData(cardId); isPassing = false,
const { isArchived } = reduxHooks.useCardCourseRunData(cardId); isArchived = false,
const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId); minPassingGrade = 0,
const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData(); 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 { formatMessage } = useIntl();
const formatDate = useFormatDate(); const formatDate = useFormatDate();
@@ -75,7 +94,7 @@ export const CertificateBanner = ({ cardId }) => {
</Banner> </Banner>
); );
} }
if (certificate.isEarnedButUnavailable) { if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) {
return ( return (
<Banner> <Banner>
{formatMessage( {formatMessage(

View File

@@ -1,26 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import CertificateBanner from './CertificateBanner'; import CertificateBanner from './CertificateBanner';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
utilHooks: { utilHooks: {
useFormatDate: jest.fn(() => date => date), useFormatDate: jest.fn(() => date => date),
}, },
reduxHooks: { useCourseData: jest.fn(),
useCardCertificateData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardGradeData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon'); jest.mock('data/hooks', () => ({
jest.unmock('@openedx/paragon/icons'); useInitializeLearnerHome: jest.fn(),
jest.unmock('@edx/frontend-platform/i18n'); }));
jest.unmock('react');
const defaultCertificate = { const defaultCertificate = {
availableDate: '10/20/3030', availableDate: '10/20/3030',
@@ -40,9 +35,14 @@ const supportEmail = 'suport@email.com';
const billingEmail = 'billing@email.com'; const billingEmail = 'billing@email.com';
describe('CertificateBanner', () => { describe('CertificateBanner', () => {
reduxHooks.useCardCourseRunData.mockReturnValue({ useCourseData.mockReturnValue({
minPassingGrade: 0.8, enrollment: {},
progressUrl: 'progressUrl', certificate: {},
gradeData: {},
courseRun: {
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
},
}); });
const createWrapper = ({ const createWrapper = ({
certificate = {}, certificate = {},
@@ -51,11 +51,17 @@ describe('CertificateBanner', () => {
courseRun = {}, courseRun = {},
platformSettings = {}, platformSettings = {},
}) => { }) => {
reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade }); useCourseData.mockReturnValue({
reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate }); enrollment: { ...defaultEnrollment, ...enrollment },
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment }); certificate: { ...defaultCertificate, ...certificate },
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun }); gradeData: { ...defaultGrade, ...grade },
reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings }); courseRun: {
...defaultCourseRun,
...courseRun,
},
});
const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } };
useInitializeLearnerHome.mockReturnValue(lernearData);
return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>); return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>);
}; };
beforeEach(() => { beforeEach(() => {
@@ -227,7 +233,8 @@ describe('CertificateBanner', () => {
isPassing: true, isPassing: true,
}, },
certificate: { certificate: {
isEarnedButUnavailable: true, isEarned: true,
availableDate: '10/20/3030',
}, },
}); });
const banner = screen.getByRole('alert'); const banner = screen.getByRole('alert');
@@ -244,4 +251,27 @@ describe('CertificateBanner', () => {
const banner = screen.queryByRole('alert'); const banner = screen.queryByRole('alert');
expect(banner).toBeNull(); 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(<IntlProvider locale="en"><CertificateBanner cardId="test-card" /></IntlProvider>);
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();
});
}); });

View File

@@ -1,21 +1,26 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon'; import { Hyperlink } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, reduxHooks } from 'hooks'; import { utilHooks, useCourseData } from 'hooks';
import Banner from 'components/Banner'; import Banner from 'components/Banner';
import messages from './messages'; import messages from './messages';
export const CourseBanner = ({ cardId }) => { export const CourseBanner = ({ cardId }) => {
const {
isVerified,
isAuditAccessExpired,
coursewareAccess = {},
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const {
isVerified = false,
isAuditAccessExpired = false,
coursewareAccess = {},
} = useMemo(() => ({
isVerified: courseData.enrollment?.isVerified,
isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired,
coursewareAccess: courseData.enrollment?.coursewareAccess || {},
}), [courseData]);
const courseRun = courseData?.courseRun || {};
const formatDate = utilHooks.useFormatDate(); const formatDate = utilHooks.useFormatDate();
const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess; const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess;

View File

@@ -1,26 +1,19 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { CourseBanner } from './CourseBanner'; import { CourseBanner } from './CourseBanner';
import messages from './messages'; import messages from './messages';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
useCourseData: jest.fn(),
utilHooks: { utilHooks: {
useFormatDate: () => date => date, useFormatDate: () => date => date,
}, },
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const enrollmentData = { const enrollmentData = {
@@ -43,13 +36,15 @@ const renderCourseBanner = (overrides = {}) => {
courseRun = {}, courseRun = {},
enrollment = {}, enrollment = {},
} = overrides; } = overrides;
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ useCourseData.mockReturnValue({
...courseRunData, courseRun: {
...courseRun, ...courseRunData,
}); ...courseRun,
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ },
...enrollmentData, enrollment: {
...enrollment, ...enrollmentData,
...enrollment,
},
}); });
return render(<IntlProvider locale="en"><CourseBanner cardId={cardId} /></IntlProvider>); return render(<IntlProvider locale="en"><CourseBanner cardId={cardId} /></IntlProvider>);
}; };
@@ -57,13 +52,20 @@ const renderCourseBanner = (overrides = {}) => {
describe('CourseBanner', () => { describe('CourseBanner', () => {
it('initializes data with course number from enrollment, course and course run data', () => { it('initializes data with course number from enrollment, course and course run data', () => {
renderCourseBanner(); renderCourseBanner();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
}); });
it('no display if learner is verified', () => { it('no display if learner is verified', () => {
renderCourseBanner({ enrollment: { isVerified: true } }); renderCourseBanner({ enrollment: { isVerified: true } });
expect(screen.queryByRole('alert')).toBeNull(); 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', () => { describe('audit access expired', () => {
it('should display correct message and link', () => { it('should display correct message and link', () => {
renderCourseBanner({ enrollment: { isAuditAccessExpired: true } }); renderCourseBanner({ enrollment: { isAuditAccessExpired: true } });

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useInitializeLearnerHome } from 'data/hooks';
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import ApprovedContent from './views/ApprovedContent'; import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent'; import EligibleContent from './views/EligibleContent';
@@ -15,9 +17,29 @@ export const statusComponents = StrictDict({
}); });
export const useCreditBannerData = (cardId) => { export const useCreditBannerData = (cardId) => {
const credit = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const { supportEmail } = reduxHooks.usePlatformSettingsData(); const { data: learnerHomeData } = useInitializeLearnerHome();
if (!credit.isEligible) { return null; } 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; const { error, purchased, requestStatus } = credit;
let ContentComponent = EligibleContent; let ContentComponent = EligibleContent;

View File

@@ -1,5 +1,6 @@
import { keyStore } from 'utils'; import { keyStore } from 'utils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import ApprovedContent from './views/ApprovedContent'; import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent'; import EligibleContent from './views/EligibleContent';
@@ -9,12 +10,19 @@ import RejectedContent from './views/RejectedContent';
import * as hooks from './hooks'; import * as hooks from './hooks';
jest.mock('hooks', () => ({ jest.mock('react', () => ({
reduxHooks: { ...jest.requireActual('react'),
useCardCreditData: jest.fn(), useMemo: (fn) => fn(),
usePlatformSettingsData: jest.fn(),
},
})); }));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('./views/ApprovedContent', () => 'ApprovedContent'); jest.mock('./views/ApprovedContent', () => 'ApprovedContent');
jest.mock('./views/EligibleContent', () => 'EligibleContent'); jest.mock('./views/EligibleContent', () => 'EligibleContent');
jest.mock('./views/MustRequestContent', () => 'MustRequestContent'); jest.mock('./views/MustRequestContent', () => 'MustRequestContent');
@@ -34,18 +42,18 @@ const defaultProps = {
}; };
const loadHook = (creditData = {}) => { const loadHook = (creditData = {}) => {
reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData }); useCourseData.mockReturnValue({ credit: { ...defaultProps, ...creditData } });
out = hooks.useCreditBannerData(cardId); out = hooks.useCreditBannerData(cardId);
}; };
describe('useCreditBannerData hook', () => { describe('useCreditBannerData hook', () => {
beforeEach(() => { beforeEach(() => {
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail }); useInitializeLearnerHome.mockReturnValue({ data: { platformSettings: { supportEmail } } });
}); });
it('loads card credit data with cardID and loads platform settings data', () => { it('loads card credit data with cardID and loads platform settings data', () => {
loadHook({ isEligible: false }); loadHook({ isEligible: false });
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.usePlatformSettingsData).toHaveBeenCalledWith(); expect(useInitializeLearnerHome).toHaveBeenCalledWith();
}); });
describe('non-credit-eligible learner', () => { describe('non-credit-eligible learner', () => {
it('returns null if the learner is not credit eligible', () => { it('returns null if the learner is not credit eligible', () => {

View File

@@ -7,10 +7,6 @@ jest.mock('./hooks', () => ({
useCreditBannerData: jest.fn(), useCreditBannerData: jest.fn(),
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
describe('CreditBanner', () => { describe('CreditBanner', () => {
const mockCardId = 'test-card-id'; const mockCardId = 'test-card-id';
const mockContentComponent = () => <div data-testid="mock-content">Test Content</div>; const mockContentComponent = () => <div data-testid="mock-content">Test Content</div>;

View File

@@ -1,17 +1,24 @@
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import CreditContent from './components/CreditContent'; import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink'; import ProviderLink from './components/ProviderLink';
import messages from './messages'; import messages from './messages';
export const ApprovedContent = ({ cardId }) => { export const ApprovedContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData(); const { providerStatusUrl: href, providerName } = useMemo(() => {
const creditData = courseData?.credit;
return {
providerStatusUrl: creditData.providerStatusUrl,
providerName: creditData.providerName,
};
}, [courseData]);
const isMasquerading = useIsMasquerading();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
return ( return (
<CreditContent <CreditContent

View File

@@ -1,34 +1,28 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import messages from './messages'; import messages from './messages';
import ApprovedContent from './ApprovedContent'; import ApprovedContent from './ApprovedContent';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCreditData: jest.fn(), useIsMasquerading: jest.fn(),
useMasqueradeData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('react');
jest.unmock('@edx/frontend-platform/i18n');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const credit = { const credit = {
providerStatusUrl: 'test-credit-provider-status-url', providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name', providerName: 'test-credit-provider-name',
}; };
reduxHooks.useCardCreditData.mockReturnValue(credit); useCourseData.mockReturnValue({ credit });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); useIsMasquerading.mockReturnValue(false);
describe('ApprovedContent component', () => { describe('ApprovedContent component', () => {
describe('hooks', () => { describe('hooks', () => {
it('initializes credit data with cardId', () => { it('initializes credit data with cardId', () => {
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>); render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('render', () => { describe('render', () => {
@@ -60,7 +54,7 @@ describe('ApprovedContent component', () => {
}); });
describe('when masquerading', () => { describe('when masquerading', () => {
beforeEach(() => { beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); useIsMasquerading.mockReturnValue(true);
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>); render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
}); });

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import CreditContent from './components/CreditContent'; import CreditContent from './components/CreditContent';
@@ -11,8 +11,9 @@ import messages from './messages';
export const EligibleContent = ({ cardId }) => { export const EligibleContent = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { providerName } = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const { courseId } = reduxHooks.useCardCourseRunData(cardId); const providerName = courseData?.credit?.providerName;
const courseId = courseData?.courseRun?.courseId;
const onClick = track.credit.purchase(courseId); const onClick = track.credit.purchase(courseId);
const getCredit = formatMessage(messages.getCredit); const getCredit = formatMessage(messages.getCredit);

View File

@@ -2,17 +2,14 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import messages from './messages'; import messages from './messages';
import EligibleContent from './EligibleContent'; import EligibleContent from './EligibleContent';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCreditData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
})); }));
jest.mock('tracking', () => ({ jest.mock('tracking', () => ({
@@ -21,17 +18,12 @@ jest.mock('tracking', () => ({
}, },
})); }));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const courseId = 'test-course-id'; const courseId = 'test-course-id';
const credit = { const credit = {
providerName: 'test-credit-provider-name', providerName: 'test-credit-provider-name',
}; };
reduxHooks.useCardCreditData.mockReturnValue(credit); useCourseData.mockReturnValue({ credit, courseRun: { courseId } });
reduxHooks.useCardCourseRunData.mockReturnValue({ courseId });
const renderEligibleContent = () => render(<IntlProvider locale="en" messages={{}}><EligibleContent cardId={cardId} /></IntlProvider>); const renderEligibleContent = () => render(<IntlProvider locale="en" messages={{}}><EligibleContent cardId={cardId} /></IntlProvider>);
@@ -39,11 +31,7 @@ describe('EligibleContent component', () => {
describe('hooks', () => { describe('hooks', () => {
it('initializes credit data with cardId', () => { it('initializes credit data with cardId', () => {
renderEligibleContent(); renderEligibleContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
});
it('initializes course run data with cardId', () => {
renderEligibleContent();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('behavior', () => { describe('behavior', () => {
@@ -67,7 +55,7 @@ describe('EligibleContent component', () => {
expect(eligibleMessage).toHaveTextContent(credit.providerName); expect(eligibleMessage).toHaveTextContent(credit.providerName);
}); });
it('message is formatted eligible message if no provider', () => { it('message is formatted eligible message if no provider', () => {
reduxHooks.useCardCreditData.mockReturnValue({}); useCourseData.mockReturnValue({ credit: {}, courseRun: { courseId } });
renderEligibleContent(); renderEligibleContent();
const eligibleMessage = screen.getByTestId('credit-msg'); const eligibleMessage = screen.getByTestId('credit-msg');
expect(eligibleMessage).toBeInTheDocument(); expect(eligibleMessage).toBeInTheDocument();

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useIsMasquerading } from 'hooks';
import CreditContent from './components/CreditContent'; import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink'; import ProviderLink from './components/ProviderLink';
import hooks from './hooks'; import hooks from './hooks';
@@ -13,7 +13,7 @@ import messages from './messages';
export const MustRequestContent = ({ cardId }) => { export const MustRequestContent = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId); const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData(); const isMasquerading = useIsMasquerading();
return ( return (
<CreditContent <CreditContent
action={{ action={{

View File

@@ -1,8 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages'; import messages from './messages';
import hooks from './hooks'; import hooks from './hooks';
import MustRequestContent from './MustRequestContent'; import MustRequestContent from './MustRequestContent';
@@ -12,16 +11,10 @@ jest.mock('./hooks', () => ({
})); }));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useMasqueradeData: jest.fn(), useIsMasquerading: jest.fn(),
useCardCreditData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const requestData = { const requestData = {
url: 'test-request-data-url', url: 'test-request-data-url',
@@ -48,10 +41,12 @@ describe('MustRequestContent component', () => {
requestData, requestData,
createCreditRequest, createCreditRequest,
}); });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); useIsMasquerading.mockReturnValue(false);
reduxHooks.useCardCreditData.mockReturnValue({ useCourseData.mockReturnValue({
providerName, credit: {
providerStatusUrl, providerName,
providerStatusUrl,
},
}); });
}); });
@@ -94,7 +89,7 @@ describe('MustRequestContent component', () => {
describe('when masquerading', () => { describe('when masquerading', () => {
beforeEach(() => { beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); useIsMasquerading.mockReturnValue(true);
renderMustRequestContent(); renderMustRequestContent();
}); });

View File

@@ -3,13 +3,14 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import CreditContent from './components/CreditContent'; import CreditContent from './components/CreditContent';
import messages from './messages'; import messages from './messages';
export const PendingContent = ({ cardId }) => { export const PendingContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData(); const { providerStatusUrl: href, providerName } = courseData?.credit || {};
const isMasquerading = useIsMasquerading();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
return ( return (
<CreditContent <CreditContent

View File

@@ -1,27 +1,25 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages'; import messages from './messages';
import PendingContent from './PendingContent'; import PendingContent from './PendingContent';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() }, useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
})); }));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const providerName = 'test-credit-provider-name'; const providerName = 'test-credit-provider-name';
const providerStatusUrl = 'test-credit-provider-status-url'; const providerStatusUrl = 'test-credit-provider-status-url';
reduxHooks.useCardCreditData.mockReturnValue({ useIsMasquerading.mockReturnValue(false);
providerName, useCourseData.mockReturnValue({
providerStatusUrl, credit: {
providerName,
providerStatusUrl,
},
}); });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const renderPendingContent = () => render( const renderPendingContent = () => render(
<IntlProvider messages={{}} locale="en"> <IntlProvider messages={{}} locale="en">
@@ -32,7 +30,7 @@ describe('PendingContent component', () => {
describe('hooks', () => { describe('hooks', () => {
it('initializes card credit data with cardId', () => { it('initializes card credit data with cardId', () => {
renderPendingContent(); renderPendingContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('behavior', () => { describe('behavior', () => {
@@ -60,7 +58,7 @@ describe('PendingContent component', () => {
}); });
describe('when masqueradeData is true', () => { describe('when masqueradeData is true', () => {
it('disables the view details button', () => { it('disables the view details button', () => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true }); useIsMasquerading.mockReturnValue(true);
renderPendingContent(); renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage }); const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).toHaveClass('disabled'); expect(button).toHaveClass('disabled');

View File

@@ -3,18 +3,19 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import CreditContent from './components/CreditContent'; import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink'; import ProviderLink from './components/ProviderLink';
import messages from './messages'; import messages from './messages';
export const RejectedContent = ({ cardId }) => { export const RejectedContent = ({ cardId }) => {
const credit = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const credit = courseData?.credit;
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
return ( return (
<CreditContent <CreditContent
message={formatMessage(messages.rejected, { message={formatMessage(messages.rejected, {
providerName: credit.providerName, providerName: credit?.providerName,
linkToProviderSite: (<ProviderLink cardId={cardId} />), linkToProviderSite: (<ProviderLink cardId={cardId} />),
})} })}
/> />

View File

@@ -1,25 +1,21 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import RejectedContent from './RejectedContent'; import RejectedContent from './RejectedContent';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCreditData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const credit = { const credit = {
providerStatusUrl: 'test-credit-provider-status-url', providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name', providerName: 'test-credit-provider-name',
}; };
reduxHooks.useCardCreditData.mockReturnValue(credit); useCourseData.mockReturnValue({
credit,
});
const renderRejectedContent = () => render(<IntlProvider><RejectedContent cardId={cardId} /></IntlProvider>); const renderRejectedContent = () => render(<IntlProvider><RejectedContent cardId={cardId} /></IntlProvider>);
@@ -27,7 +23,7 @@ describe('RejectedContent component', () => {
describe('hooks', () => { describe('hooks', () => {
it('initializes credit data with cardId', () => { it('initializes credit data with cardId', () => {
renderRejectedContent(); renderRejectedContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('render', () => { describe('render', () => {

View File

@@ -2,9 +2,6 @@ import { render, screen } from '@testing-library/react';
import CreditContent from './CreditContent'; import CreditContent from './CreditContent';
jest.unmock('@openedx/paragon');
jest.unmock('react');
const action = { const action = {
href: 'test-action-href', href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'), onClick: jest.fn().mockName('test-action-onClick'),

View File

@@ -4,11 +4,11 @@ import useCreditRequestFormData from './hooks';
const requestData = 'test-request-data'; const requestData = 'test-request-data';
// jest.mock('react', () => ({ jest.mock('react', () => ({
// ...jest.requireActual('react'), ...jest.requireActual('react'),
// useRef: jest.fn((val) => ({ current: val, useRef: true })), useRef: jest.fn((val) => ({ current: val, useRef: true })),
// useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })), useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
// })); }));
let out; let out;
const ref = { const ref = {

View File

@@ -10,9 +10,6 @@ jest.mock('./hooks', () => ({
default: jest.fn(), default: jest.fn(),
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('react');
const ref = { current: { click: jest.fn() }, useRef: jest.fn() }; const ref = { current: { click: jest.fn() }, useRef: jest.fn() };
const requestData = { const requestData = {

View File

@@ -4,9 +4,6 @@ import { render } from '@testing-library/react';
import useCreditRequestFormData from './hooks'; import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.'; import CreditRequestForm from '.';
jest.unmock('@openedx/paragon');
jest.unmock('react');
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
__esModule: true, __esModule: true,
default: jest.fn(), default: jest.fn(),

View File

@@ -2,11 +2,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import { Hyperlink } from '@openedx/paragon'; import { Hyperlink } from '@openedx/paragon';
export const ProviderLink = ({ cardId }) => { export const ProviderLink = ({ cardId }) => {
const credit = reduxHooks.useCardCreditData(cardId); const courseData = useCourseData(cardId);
const credit = courseData?.credit || {};
return ( return (
<Hyperlink <Hyperlink
href={credit.providerStatusUrl} href={credit.providerStatusUrl}

View File

@@ -1,19 +1,13 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { reduxHooks } from 'hooks';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import ProviderLink from './ProviderLink'; import ProviderLink from './ProviderLink';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCreditData: jest.fn(),
},
})); }));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
const credit = { const credit = {
providerStatusUrl: 'test-credit-provider-status-url', providerStatusUrl: 'test-credit-provider-status-url',
@@ -27,12 +21,12 @@ const renderProviderLink = () => render(
describe('ProviderLink component', () => { describe('ProviderLink component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useCardCreditData.mockReturnValue(credit); useCourseData.mockReturnValue({ credit });
renderProviderLink(); renderProviderLink();
}); });
describe('hooks', () => { describe('hooks', () => {
it('initializes credit hook with cardId', () => { it('initializes credit hook with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
}); });
}); });
describe('render', () => { describe('render', () => {

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { apiHooks } from 'hooks'; import { useCourseData } from 'hooks';
import { useCreateCreditRequest } from 'data/hooks';
import * as module from './hooks'; import * as module from './hooks';
@@ -11,13 +12,19 @@ export const state = StrictDict({
export const useCreditRequestData = (cardId) => { export const useCreditRequestData = (cardId) => {
const [requestData, setRequestData] = module.state.creditRequestData(null); const [requestData, setRequestData] = module.state.creditRequestData(null);
const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId); const courseData = useCourseData(cardId);
const providerId = courseData?.credit?.providerId;
const { authenticatedUser: { username } } = React.useContext(AppContext);
const courseId = courseData?.courseRun?.courseId;
const { mutate: createCreditMutation } = useCreateCreditRequest();
const createCreditRequest = (e) => { const createCreditRequest = (e) => {
e.preventDefault(); e.preventDefault();
createCreditApiRequest() createCreditMutation({ providerId, courseId, username }, {
.then((request) => { onSuccess: (response) => {
setRequestData(request.data); setRequestData(response.data);
}); },
});
}; };
return { requestData, createCreditRequest }; return { requestData, createCreditRequest };
}; };

View File

@@ -1,56 +0,0 @@
import { MockUseState } from 'testUtils';
import { apiHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
apiHooks: {
useCreateCreditRequest: jest.fn(),
},
}));
const state = new MockUseState(hooks);
const cardId = 'test-card-id';
const requestData = { data: 'request data' };
const creditRequest = jest.fn().mockReturnValue(Promise.resolve(requestData));
apiHooks.useCreateCreditRequest.mockReturnValue(creditRequest);
const event = { preventDefault: jest.fn() };
let out;
describe('Credit Banner view hooks', () => {
describe('state', () => {
state.testGetter(state.keys.creditRequestData);
});
describe('useCreditRequestData', () => {
beforeEach(() => {
state.mock();
out = hooks.useCreditRequestData(cardId);
});
describe('behavior', () => {
it('initializes creditRequestData state field with null value', () => {
state.expectInitializedWith(state.keys.creditRequestData, null);
});
it('calls useCreateCreditRequest with passed cardID', () => {
expect(apiHooks.useCreateCreditRequest).toHaveBeenCalledWith(cardId);
});
});
describe('output', () => {
it('returns requestData state value', () => {
state.mockVal(state.keys.creditRequestData, requestData);
out = hooks.useCreditRequestData(cardId);
expect(out.requestData).toEqual(requestData);
});
describe('createCreditRequest', () => {
it('returns an event handler that prevents default click behavior', () => {
out.createCreditRequest(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('calls api.createCreditRequest and sets requestData with the response', async () => {
await out.createCreditRequest(event);
expect(creditRequest).toHaveBeenCalledWith();
expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData.data);
});
});
});
});
});

View File

@@ -0,0 +1,192 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import * as api from 'data/services/lms/api';
import { useCourseData } from 'hooks';
import { AppContext } from '@edx/frontend-platform/react';
import * as hooks from './hooks';
jest.mock('data/services/lms/api', () => ({
createCreditRequest: jest.fn(),
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={{
authenticatedUser: { username: 'test-user' },
}}
>
{children}
</AppContext.Provider>
</QueryClientProvider>
);
return wrapper;
};
describe('useCreditRequestData', () => {
let wrapper;
beforeEach(() => {
wrapper = createWrapper();
(useCourseData as jest.Mock).mockReturnValue({
credit: { providerId: 'provider-123' },
courseRun: { courseId: 'course-456' },
});
jest.clearAllMocks();
});
it('initializes requestData as null', () => {
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
expect(result.current.requestData).toBeNull();
});
it('returns createCreditRequest function', () => {
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
expect(typeof result.current.createCreditRequest).toBe('function');
});
it('prevents default event behavior', async () => {
const event = { preventDefault: jest.fn() };
(api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' });
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(event.preventDefault).toHaveBeenCalled();
});
it('calls API with correct parameters', async () => {
const event = { preventDefault: jest.fn() };
(api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' });
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(api.createCreditRequest).toHaveBeenCalledWith({
providerId: 'provider-123',
courseId: 'course-456',
username: 'test-user',
});
});
it('sets requestData with response data on success', async () => {
const event = { preventDefault: jest.fn() };
const responseData = { data: { id: 'credit-123', status: 'pending' } };
(api.createCreditRequest as jest.Mock).mockResolvedValue(responseData);
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(api.createCreditRequest).toHaveBeenCalledWith({
providerId: 'provider-123',
courseId: 'course-456',
username: 'test-user',
});
await waitFor(() => {
expect(result.current.requestData).toEqual(responseData.data);
});
});
it('handles missing providerId gracefully', async () => {
const event = { preventDefault: jest.fn() };
(useCourseData as jest.Mock).mockReturnValue({
credit: null,
courseRun: { courseId: 'course-456' },
});
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(api.createCreditRequest).toHaveBeenCalledWith({
providerId: undefined,
courseId: 'course-456',
username: 'test-user',
});
});
it('handles missing courseId gracefully', async () => {
const event = { preventDefault: jest.fn() };
(useCourseData as jest.Mock).mockReturnValue({
credit: { providerId: 'provider-123' },
courseRun: null,
});
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(api.createCreditRequest).toHaveBeenCalledWith({
providerId: 'provider-123',
courseId: undefined,
username: 'test-user',
});
});
it('handles API errors without crashing', async () => {
const event = { preventDefault: jest.fn() };
(api.createCreditRequest as jest.Mock).mockRejectedValue(new Error('API Error'));
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
expect(result.current.requestData).toBeNull();
});
it('uses cardId to fetch course data', () => {
renderHook(() => hooks.useCreditRequestData('different-card'), { wrapper });
expect(useCourseData).toHaveBeenCalledWith('different-card');
});
it('handles undefined response data', async () => {
const event = { preventDefault: jest.fn() };
(api.createCreditRequest as jest.Mock).mockResolvedValue({ status: 200 });
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
await act(async () => {
result.current.createCreditRequest(event);
});
await waitFor(() => {
expect(result.current.requestData).toBeUndefined();
});
});
});

View File

@@ -1,16 +1,21 @@
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, MailtoLink } from '@openedx/paragon'; import { Button, MailtoLink } from '@openedx/paragon';
import { utilHooks, reduxHooks } from 'hooks'; import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import Banner from 'components/Banner'; import Banner from 'components/Banner';
import { useInitializeLearnerHome } from 'data/hooks';
import messages from './messages'; import messages from './messages';
export const EntitlementBanner = ({ cardId }) => { export const EntitlementBanner = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const { const {
isEntitlement, isEntitlement,
hasSessions, hasSessions,
@@ -18,9 +23,12 @@ export const EntitlementBanner = ({ cardId }) => {
changeDeadline, changeDeadline,
showExpirationWarning, showExpirationWarning,
isExpired, isExpired,
} = reduxHooks.useCardEntitlementData(cardId); } = useEntitlementInfo(courseData);
const { supportEmail } = reduxHooks.usePlatformSettingsData(); const supportEmail = useMemo(
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); () => learnerHomeData?.platformSettings?.supportEmail,
[learnerHomeData],
);
const { updateSelectSessionModal } = useSelectSessionModal();
const formatDate = utilHooks.useFormatDate(); const formatDate = utilHooks.useFormatDate();
if (!isEntitlement) { if (!isEntitlement) {
@@ -42,7 +50,7 @@ export const EntitlementBanner = ({ cardId }) => {
{formatMessage(messages.entitlementExpiringSoon, { {formatMessage(messages.entitlementExpiringSoon, {
changeDeadline: formatDate(changeDeadline), changeDeadline: formatDate(changeDeadline),
selectSessionButton: ( selectSessionButton: (
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}> <Button variant="link" size="inline" className="m-0 p-0" onClick={() => updateSelectSessionModal(cardId)}>
{formatMessage(messages.selectSession)} {formatMessage(messages.selectSession)}
</Button> </Button>
), ),

View File

@@ -1,27 +1,41 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import EntitlementBanner from './EntitlementBanner'; import EntitlementBanner from './EntitlementBanner';
import messages from './messages'; import messages from './messages';
jest.mock('hooks', () => ({ jest.mock('react', () => ({
utilHooks: { ...jest.requireActual('react'),
useFormatDate: () => date => date, useMemo: (fn) => fn(),
},
reduxHooks: {
usePlatformSettingsData: jest.fn(),
useCardEntitlementData: jest.fn(),
useUpdateSelectSessionModalCallback: jest.fn(
(cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`),
),
},
})); }));
jest.unmock('@edx/frontend-platform/i18n'); jest.mock('data/hooks', () => ({
jest.unmock('@openedx/paragon'); useInitializeLearnerHome: jest.fn().mockReturnValue({
jest.unmock('react'); data: {
platformSettings: {
supportEmail: 'test-support-email',
},
},
}),
}));
const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal');
jest.mock('data/context/SelectSessionProvider', () => ({
useSelectSessionModal: () => ({
updateSelectSessionModal: mockUpdateSelectSessionModal,
}),
}));
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
utilHooks: {
useFormatDate: () => date => date?.toDateString(),
},
}));
const cardId = 'test-card-id'; const cardId = 'test-card-id';
@@ -36,16 +50,20 @@ const platformData = { supportEmail: 'test-support-email' };
const renderComponent = (overrides = {}) => { const renderComponent = (overrides = {}) => {
const { entitlement = {} } = overrides; const { entitlement = {} } = overrides;
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement }); useCourseData.mockReturnValue({
reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData); entitlement: { ...entitlementData, ...entitlement },
platformSettings: platformData,
});
return render(<IntlProvider locale="en"><EntitlementBanner cardId={cardId} /></IntlProvider>); return render(<IntlProvider locale="en"><EntitlementBanner cardId={cardId} /></IntlProvider>);
}; };
describe('EntitlementBanner', () => { describe('EntitlementBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes data with course number from entitlement', () => { it('initializes data with course number from entitlement', () => {
renderComponent(); renderComponent();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId);
}); });
it('no display if not an entitlement', () => { it('no display if not an entitlement', () => {
renderComponent({ entitlement: { isEntitlement: false } }); renderComponent({ entitlement: { isEntitlement: false } });
@@ -60,7 +78,10 @@ describe('EntitlementBanner', () => {
expect(banner.innerHTML).toContain(platformData.supportEmail); expect(banner.innerHTML).toContain(platformData.supportEmail);
}); });
it('renders when expiration warning', () => { 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'); const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument(); expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-info'); expect(banner).toHaveClass('alert-info');
@@ -68,9 +89,37 @@ describe('EntitlementBanner', () => {
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
}); });
it('renders expired banner', () => { it('renders expired banner', () => {
renderComponent({ entitlement: { isExpired: true } }); renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } });
const banner = screen.getByRole('alert'); const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument(); expect(banner).toBeInTheDocument();
expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired)); 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();
});
}); });

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { Program } from '@openedx/paragon/icons'; import { Program } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import Banner from 'components/Banner'; import Banner from 'components/Banner';
import ProgramList from './ProgramsList'; import ProgramList from './ProgramsList';
@@ -12,10 +12,10 @@ import messages from './messages';
export const RelatedProgramsBanner = ({ cardId }) => { export const RelatedProgramsBanner = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const programData = courseData?.programs;
const programData = reduxHooks.useCardRelatedProgramsData(cardId); if (!courseData || !programData?.relatedPrograms.length) {
if (!programData?.length) {
return null; return null;
} }
@@ -27,7 +27,7 @@ export const RelatedProgramsBanner = ({ cardId }) => {
<span className="font-weight-bolder"> <span className="font-weight-bolder">
{formatMessage(messages.relatedPrograms)} {formatMessage(messages.relatedPrograms)}
</span> </span>
<ProgramList programs={programData.list} /> <ProgramList programs={programData.relatedPrograms} />
</Banner> </Banner>
); );
}; };

View File

@@ -1,18 +1,11 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import RelatedProgramsBanner from '.'; import RelatedProgramsBanner from '.';
jest.unmock('@openedx/paragon');
jest.unmock('@openedx/paragon/icons');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardRelatedProgramsData: jest.fn(),
},
})); }));
const cardId = 'test-card-id'; const cardId = 'test-card-id';
@@ -32,21 +25,21 @@ const programData = {
describe('RelatedProgramsBanner', () => { describe('RelatedProgramsBanner', () => {
it('render empty', () => { it('render empty', () => {
reduxHooks.useCardRelatedProgramsData.mockReturnValue({}); useCourseData.mockReturnValue(null);
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>); render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
const banner = screen.queryByRole('alert'); const banner = screen.queryByRole('alert');
expect(banner).toBeNull(); expect(banner).toBeNull();
}); });
it('render with programs', () => { it('render with programs', () => {
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>); render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
const list = screen.getByRole('list'); const list = screen.getByRole('list');
expect(list.childElementCount).toBe(programData.list.length); expect(list.childElementCount).toBe(programData.list.length);
}); });
it('render related programs title', () => { it('render related programs title', () => {
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>); render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
const title = screen.getByText('Related Programs:'); const title = screen.getByText('Related Programs:');
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument();

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import CourseBannerSlot from 'plugin-slots/CourseBannerSlot'; import CourseBannerSlot from 'plugin-slots/CourseBannerSlot';
import CertificateBanner from './CertificateBanner'; import CertificateBanner from './CertificateBanner';
@@ -10,7 +10,11 @@ import EntitlementBanner from './EntitlementBanner';
import RelatedProgramsBanner from './RelatedProgramsBanner'; import RelatedProgramsBanner from './RelatedProgramsBanner';
export const CourseCardBanners = ({ cardId }) => { export const CourseCardBanners = ({ cardId }) => {
const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId); const courseData = useCourseData(cardId);
if (!courseData) {
return null;
}
const { isEnrolled = false } = courseData.enrollment;
return ( return (
<div className="course-card-banners" data-testid="CourseCardBanners"> <div className="course-card-banners" data-testid="CourseCardBanners">
<RelatedProgramsBanner cardId={cardId} /> <RelatedProgramsBanner cardId={cardId} />

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import CourseCardBanners from '.'; import CourseCardBanners from '.';
@@ -20,9 +20,11 @@ const mockedComponents = [
]; ];
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(() => ({
useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })), enrollment: {
}, isEnrolled: true,
},
})),
})); }));
describe('CourseCardBanners', () => { describe('CourseCardBanners', () => {
@@ -36,8 +38,13 @@ describe('CourseCardBanners', () => {
return expect(mockedComponent).toBeInTheDocument(); return expect(mockedComponent).toBeInTheDocument();
}); });
}); });
it('render null with no courseData', () => {
useCourseData.mockReturnValue(null);
const { container } = render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
expect(container.firstChild).toBeNull();
});
it('render with isEnrolled false', () => { it('render with isEnrolled false', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false }); useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } });
render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>); render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2); const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2);
mockedComponentsIfNotEnrolled.map((componentName) => { mockedComponentsIfNotEnrolled.map((componentName) => {

View File

@@ -1,20 +1,21 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, reduxHooks } from 'hooks'; import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import * as hooks from './hooks'; import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
export const useAccessMessage = ({ cardId }) => { export const useAccessMessage = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const enrollment = reduxHooks.useCardEnrollmentData(cardId); const courseData = useCourseData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId); const { courseRun, enrollment } = courseData || {};
const formatDate = utilHooks.useFormatDate(); const formatDate = utilHooks.useFormatDate();
if (!courseRun.isStarted) { if (!courseRun.isStarted) {
if (!courseRun.startDate && !courseRun.advertisedStart) { return null; } if (!courseRun.startDate && !courseRun.advertisedStart) { return null; }
const startDate = courseRun.advertisedStart ? courseRun.advertisedStart : formatDate(courseRun.startDate); const startDate = courseRun.advertisedStart ? courseRun.advertisedStart : formatDate(courseRun.startDate);
return formatMessage(messages.courseStarts, { startDate }); return formatMessage(messages.courseStarts, { startDate });
} }
if (enrollment.isEnrolled) { if (enrollment?.isEnrolled) {
const { isArchived, endDate } = courseRun; const { isArchived, endDate } = courseRun;
const { const {
accessExpirationDate, accessExpirationDate,
@@ -38,15 +39,15 @@ export const useAccessMessage = ({ cardId }) => {
export const useCardDetailsData = ({ cardId }) => { export const useCardDetailsData = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const providerName = reduxHooks.useCardProviderData(cardId).name; const courseData = useCourseData(cardId);
const { courseNumber } = reduxHooks.useCardCourseData(cardId); const providerName = courseData?.courseProvider?.name;
const courseNumber = courseData?.course?.courseNumber;
const { const {
isEntitlement, isEntitlement,
isFulfilled, isFulfilled,
canChange, canChange,
} = reduxHooks.useCardEntitlementData(cardId); } = useEntitlementInfo(courseData);
const { updateSelectSessionModal } = useSelectSessionModal();
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
return { return {
providerName: providerName || formatMessage(messages.unknownProviderName), providerName: providerName || formatMessage(messages.unknownProviderName),
@@ -54,7 +55,7 @@ export const useCardDetailsData = ({ cardId }) => {
isEntitlement, isEntitlement,
isFulfilled, isFulfilled,
canChange, canChange,
openSessionModal, openSessionModal: () => updateSelectSessionModal(cardId),
courseNumber, courseNumber,
changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton), changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton),
}; };

View File

@@ -1,34 +1,37 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { keyStore } from 'utils'; import { keyStore } from 'utils';
import { utilHooks, reduxHooks } from 'hooks'; import { utilHooks, useCourseData } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import * as hooks from './hooks'; import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: (fn) => fn(),
}));
const updateSelectSessionModalMock = jest.fn().mockName('updateSelectSessionModal');
jest.mock('data/context', () => ({
useSelectSessionModal: jest.fn(),
}));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
utilHooks: { utilHooks: {
useFormatDate: jest.fn(), useFormatDate: jest.fn(),
}, },
reduxHooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useCardProviderData: jest.fn(),
useUpdateSelectSessionModalCallback: (...args) => ({ updateSelectSessionModalCallback: args }),
},
})); }));
// jest.mock('@edx/frontend-platform/i18n', () => { jest.mock('@edx/frontend-platform/i18n', () => {
// const { formatMessage } = jest.requireActual('testUtils'); const { formatMessage } = jest.requireActual('testUtils');
// return { return {
// ...jest.requireActual('@edx/frontend-platform/i18n'), ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({ useIntl: () => ({
// formatMessage, formatMessage,
// }), }),
// }; };
// }); });
const cardId = 'my-test-card-id'; const cardId = 'my-test-card-id';
const courseNumber = 'test-course-number'; const courseNumber = 'test-course-number';
@@ -60,15 +63,13 @@ describe('CourseCardDetails hooks', () => {
const runHook = ({ provider = {}, entitlement = {} }) => { const runHook = ({ provider = {}, entitlement = {} }) => {
jest.spyOn(hooks, hookKeys.useAccessMessage) jest.spyOn(hooks, hookKeys.useAccessMessage)
.mockImplementationOnce(mockAccessMessage); .mockImplementationOnce(mockAccessMessage);
reduxHooks.useCardProviderData.mockReturnValueOnce({ useCourseData.mockReturnValue({
...providerData, courseProvider: { ...providerData, ...provider },
...provider, course: { courseNumber },
courseRun: {},
entitlement: { ...entitlementData, ...entitlement },
}); });
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock });
...entitlementData,
...entitlement,
});
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber });
out = hooks.useCardDetailsData({ cardId }); out = hooks.useCardDetailsData({ cardId });
}; };
beforeEach(() => { beforeEach(() => {
@@ -85,6 +86,10 @@ describe('CourseCardDetails hooks', () => {
it('forward changeOrLeaveSessionMessage', () => { it('forward changeOrLeaveSessionMessage', () => {
expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton)); expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton));
}); });
it('calls updateSelectSessionModal when openSessionModal is called', () => {
out.openSessionModal();
expect(updateSelectSessionModalMock).toHaveBeenCalledWith(cardId);
});
}); });
describe('useAccessMessage', () => { describe('useAccessMessage', () => {
@@ -101,21 +106,16 @@ describe('CourseCardDetails hooks', () => {
endDate: '10/20/2000', endDate: '10/20/2000',
}; };
const runHook = ({ enrollment = {}, courseRun = {} }) => { const runHook = ({ enrollment = {}, courseRun = {} }) => {
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ useCourseData.mockReturnValue({
...courseRunData, courseRun: { ...courseRunData, ...courseRun },
...courseRun, enrollment: { ...enrollmentData, ...enrollment },
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
}); });
out = hooks.useAccessMessage({ cardId }); out = hooks.useAccessMessage({ cardId });
}; };
it('loads data from enrollment and course run data based on course number', () => { it('loads data from enrollment and course run data based on course number', () => {
runHook({}); runHook({});
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
}); });
describe('if not started yet', () => { describe('if not started yet', () => {

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { baseAppUrl } from 'data/services/lms/urls';
import { Badge } from '@openedx/paragon'; import { Badge } from '@openedx/paragon';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import verifiedRibbon from 'assets/verified-ribbon.png'; import verifiedRibbon from 'assets/verified-ribbon.png';
import useActionDisabledState from './hooks'; import useActionDisabledState from './hooks';
@@ -15,11 +16,10 @@ const { courseImageClicked } = track.course;
export const CourseCardImage = ({ cardId, orientation }) => { export const CourseCardImage = ({ cardId, orientation }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId); const courseData = useCourseData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); const { homeUrl } = courseData?.courseRun || {};
const { isVerified } = reduxHooks.useCardEnrollmentData(cardId);
const { disableCourseTitle } = useActionDisabledState(cardId); 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 wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`;
const image = ( 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 // w-100 is necessary for images on Safari, otherwise stretches full height of the image
// https://stackoverflow.com/a/44250830 // https://stackoverflow.com/a/44250830
className="pgn__card-image-cap w-100 show" className="pgn__card-image-cap w-100 show"
src={bannerImgSrc} src={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)}
alt={formatMessage(messages.bannerAlt)} alt={formatMessage(messages.bannerAlt)}
/> />
{ {
isVerified && ( courseData?.enrollment?.isVerified && (
<span <span
className="course-card-verify-ribbon-container" className="course-card-verify-ribbon-container"
title={formatMessage(messages.verifiedHoverDescription)} title={formatMessage(messages.verifiedHoverDescription)}

View File

@@ -1,27 +1,23 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import useActionDisabledState from './hooks'; import useActionDisabledState from './hooks';
import { CourseCardImage } from './CourseCardImage'; import { CourseCardImage } from './CourseCardImage';
import messages from '../messages'; import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const homeUrl = 'https://example.com'; const homeUrl = 'https://example.com';
const bannerImgSrc = 'banner-img-src.jpg'; const bannerImgSrc = 'banner-img-src.jpg';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(() => ({
useCardCourseData: jest.fn(() => ({ bannerImgSrc })), course: { bannerImgSrc },
useCardCourseRunData: jest.fn(() => ({ homeUrl })), courseRun: { homeUrl },
useCardEnrollmentData: jest.fn(), enrollment: {},
useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({ })),
trackCourseEvent: { eventName, cardId, url }, useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({
})), trackCourseEvent: { eventName, cardId, url },
}, })),
})); }));
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
@@ -34,7 +30,13 @@ describe('CourseCardImage', () => {
it('renders course image with correct attributes', () => { it('renders course image with correct attributes', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: true }); useActionDisabledState.mockReturnValue({ disableCourseTitle: true });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: true },
},
);
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>); render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) }); const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) });
@@ -45,7 +47,13 @@ describe('CourseCardImage', () => {
it('isVerified, should render badge', () => { it('isVerified, should render badge', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: true },
},
);
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>); render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const badge = screen.getByText(formatMessage(messages.verifiedBanner)); const badge = screen.getByText(formatMessage(messages.verifiedBanner));
@@ -56,7 +64,13 @@ describe('CourseCardImage', () => {
it('renders link with correct href if disableCourseTitle is false', () => { it('renders link with correct href if disableCourseTitle is false', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: false }); useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: false },
},
);
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>); render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const link = screen.getByRole('link'); const link = screen.getByRole('link');
@@ -65,12 +79,15 @@ describe('CourseCardImage', () => {
describe('hooks', () => { describe('hooks', () => {
it('initializes', () => { it('initializes', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); useCourseData.mockReturnValue(
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>); {
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); course: { bannerImgSrc },
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith( courseRun: { homeUrl },
props.cardId, enrollment: { isVerified: true },
},
); );
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
}); });
}); });

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as ReactShare from 'react-share'; import * as ReactShare from 'react-share';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon'; import { Dropdown } from '@openedx/paragon';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
import { useCardSocialSettingsData } from './hooks';
import messages from './messages'; import messages from './messages';
export const testIds = { export const testIds = {
@@ -16,14 +16,15 @@ export const testIds = {
export const SocialShareMenu = ({ cardId, emailSettings }) => { export const SocialShareMenu = ({ cardId, emailSettings }) => {
const { formatMessage } = useIntl(); 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 handleTwitterShare = useCourseTrackingEvent(track.socialShare, cardId, 'twitter');
const { isEmailEnabled, isExecEd2UCourse } = reduxHooks.useCardEnrollmentData(cardId); const handleFacebookShare = useCourseTrackingEvent(track.socialShare, cardId, 'facebook');
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const handleTwitterShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'twitter');
const handleFacebookShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'facebook');
if (isExecEd2UCourse) { if (isExecEd2UCourse) {
return null; return null;
@@ -50,6 +51,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
})} })}
resetButtonStyle={false} resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item" className="pgn__dropdown-item dropdown-item"
aria-label="facebook"
> >
{formatMessage(messages.shareToFacebook)} {formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton> </ReactShare.FacebookShareButton>
@@ -64,6 +66,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
})} })}
resetButtonStyle={false} resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item" className="pgn__dropdown-item dropdown-item"
aria-label="twitter"
> >
{formatMessage(messages.shareToTwitter)} {formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton> </ReactShare.TwitterShareButton>

View File

@@ -4,9 +4,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
import { useEmailSettings } from './hooks'; import { useEmailSettings, useCardSocialSettingsData } from './hooks';
import SocialShareMenu from './SocialShareMenu'; import SocialShareMenu from './SocialShareMenu';
import messages from './messages'; import messages from './messages';
@@ -15,22 +15,15 @@ jest.mock('tracking', () => ({
})); }));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useMasqueradeData: jest.fn(), useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
useCardCourseData: jest.fn(), useIsMasquerading: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
},
})); }));
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(), useEmailSettings: jest.fn(),
useCardSocialSettingsData: jest.fn(),
})); }));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const props = { const props = {
cardId: 'test-card-id', cardId: 'test-card-id',
emailSettings: { show: jest.fn() }, emailSettings: { show: jest.fn() },
@@ -61,23 +54,25 @@ const socialShare = {
const mockHooks = (returnVals = {}) => { const mockHooks = (returnVals = {}) => {
mockHook( mockHook(
reduxHooks.useCardEnrollmentData, useCourseData,
{ {
isEmailEnabled: !!returnVals.isEmailEnabled, enrollment: {
isExecEd2UCourse: !!returnVals.isExecEd2UCourse, isEmailEnabled: !!returnVals.isEmailEnabled,
mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard',
},
course: { courseName },
}, },
{ isCardHook: true }, { isCardHook: true },
); );
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook( mockHook(
reduxHooks.useCardSocialSettingsData, useCardSocialSettingsData,
{ {
facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled }, facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled }, twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled },
}, },
{ isCardHook: true }, { isCardHook: true },
); );
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
}; };
const renderComponent = () => render(<IntlProvider locale="en"><SocialShareMenu {...props} /></IntlProvider>); const renderComponent = () => render(<IntlProvider locale="en"><SocialShareMenu {...props} /></IntlProvider>);
@@ -91,13 +86,12 @@ describe('SocialShareMenu', () => {
it('initializes local hooks', () => { it('initializes local hooks', () => {
when(useEmailSettings).expectCalledWith(); when(useEmailSettings).expectCalledWith();
}); });
it('initializes redux hook data ', () => { it('initializes hook data ', () => {
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); when(useCourseData).expectCalledWith(props.cardId);
when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId); when(useCardSocialSettingsData).expectCalledWith(props.cardId);
when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId); when(useIsMasquerading).expectCalledWith();
when(reduxHooks.useMasqueradeData).expectCalledWith(); when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter'); when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
}); });
}); });
describe('render', () => { describe('render', () => {

View File

@@ -1,7 +1,8 @@
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useState } from 'react'; import { useState } from 'react';
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { useInitializeLearnerHome } from 'data/hooks';
export const state = StrictDict({ export const state = StrictDict({
isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line
@@ -27,7 +28,7 @@ export const useEmailSettings = () => {
}; };
export const useHandleToggleDropdown = (cardId) => { export const useHandleToggleDropdown = (cardId) => {
const trackCourseEvent = reduxHooks.useTrackCourseEvent( const trackCourseEvent = useCourseTrackingEvent(
track.course.courseOptionsDropdownClicked, track.course.courseOptionsDropdownClicked,
cardId, cardId,
); );
@@ -36,10 +37,30 @@ export const useHandleToggleDropdown = (cardId) => {
}; };
}; };
export const useCardSocialSettingsData = (cardId) => {
const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const socialShareSettings = learnerHomeData?.socialShareSettings;
const { socialShareUrl } = courseData?.course || {};
const defaultSettings = { isEnabled: false, shareUrl: '' };
if (!socialShareSettings) {
return { facebook: defaultSettings, twitter: defaultSettings };
}
const { facebook, twitter } = socialShareSettings;
const loadSettings = (target) => ({
isEnabled: target.isEnabled,
shareUrl: `${socialShareUrl}?${target.utmParams}`,
});
return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) };
};
export const useOptionVisibility = (cardId) => { export const useOptionVisibility = (cardId) => {
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId); const courseData = useCourseData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId); const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
const { isEarned } = reduxHooks.useCardCertificateData(cardId); const isEnrolled = courseData?.enrollment?.isEnrolled ?? false;
const { twitter, facebook } = useCardSocialSettingsData(cardId);
const isEarned = courseData?.certificate?.isEarned ?? false;
const shouldShowUnenrollItem = isEnrolled && !isEarned; const shouldShowUnenrollItem = isEnrolled && !isEarned;
const shouldShowDropdown = ( const shouldShowDropdown = (

View File

@@ -1,20 +1,21 @@
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import track from 'tracking'; import track from 'tracking';
import { MockUseState } from 'testUtils'; import { MockUseState } from 'testUtils';
import * as hooks from './hooks'; import * as hooks from './hooks';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCertificateData: jest.fn(), useCourseTrackingEvent: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
})); }));
const trackCourseEvent = jest.fn(); const trackCourseEvent = jest.fn();
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent); useCourseTrackingEvent.mockReturnValue(trackCourseEvent);
const cardId = 'test-card-id'; const cardId = 'test-card-id';
let out; let out;
@@ -71,7 +72,7 @@ describe('CourseCardMenu hooks', () => {
beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); }); beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); });
describe('behavior', () => { describe('behavior', () => {
it('initializes course event tracker with event name and card ID', () => { it('initializes course event tracker with event name and card ID', () => {
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.courseOptionsDropdownClicked, track.course.courseOptionsDropdownClicked,
cardId, cardId,
); );
@@ -88,55 +89,61 @@ describe('CourseCardMenu hooks', () => {
}); });
describe('useOptionVisibility', () => { describe('useOptionVisibility', () => {
const mockReduxHooks = (returnVals = {}) => { const mockHooks = (returnVals = {}) => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({ useInitializeLearnerHome.mockReturnValue({
facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, data: {
twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, socialShareSettings: {
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
},
},
}); });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ useCourseData.mockReturnValue({
isEnrolled: !!returnVals.isEnrolled, enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled, isEnrolled: !!returnVals.isEnrolled,
}); isEmailEnabled: !!returnVals.isEmailEnabled,
reduxHooks.useCardCertificateData.mockReturnValueOnce({ },
isEarned: !!returnVals.isEarned, certificate: {
isEarned: !!returnVals.isEarned,
},
}); });
}; };
describe('shouldShowUnenrollItem', () => { describe('shouldShowUnenrollItem', () => {
it('returns true if enrolled and not earned', () => { it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true }); mockHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true);
}); });
it('returns false if not enrolled', () => { it('returns false if not enrolled', () => {
mockReduxHooks(); mockHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
}); });
it('returns false if enrolled but also earned', () => { it('returns false if enrolled but also earned', () => {
mockReduxHooks({ isEarned: true }); mockHooks({ isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
}); });
}); });
describe('shouldShowDropdown', () => { describe('shouldShowDropdown', () => {
it('returns false if not enrolled and both email and socials are disabled', () => { it('returns false if not enrolled and both email and socials are disabled', () => {
mockReduxHooks(); mockHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
}); });
it('returns false if enrolled but already earned, and both email and socials are disabled', () => { 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); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
}); });
it('returns true if either social is enabled', () => { it('returns true if either social is enabled', () => {
mockReduxHooks({ facebook: { isEnabled: true } }); mockHooks({ facebook: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
mockReduxHooks({ twitter: { isEnabled: true } }); mockHooks({ twitter: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
}); });
it('returns true if email is enabled', () => { it('returns true if email is enabled', () => {
mockReduxHooks({ isEmailEnabled: true }); mockHooks({ isEmailEnabled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
}); });
it('returns true if enrolled and not earned', () => { it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true }); mockHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
}); });
}); });

View File

@@ -6,7 +6,7 @@ import { MoreVert } from '@openedx/paragon/icons';
import EmailSettingsModal from 'containers/EmailSettingsModal'; import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal'; import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import SocialShareMenu from './SocialShareMenu'; import SocialShareMenu from './SocialShareMenu';
import { import {
useEmailSettings, useEmailSettings,
@@ -23,13 +23,15 @@ export const testIds = {
export const CourseCardMenu = ({ cardId }) => { export const CourseCardMenu = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
const emailSettings = useEmailSettings(); const emailSettings = useEmailSettings();
const unenrollModal = useUnenrollData(); const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId); const handleToggleDropdown = useHandleToggleDropdown(cardId);
const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId); const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData(); const isMasquerading = useIsMasquerading();
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
if (!shouldShowDropdown) { if (!shouldShowDropdown) {
return null; return null;

View File

@@ -4,16 +4,14 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import * as hooks from './hooks'; import * as hooks from './hooks';
import CourseCardMenu from '.'; import CourseCardMenu from '.';
import messages from './messages'; import messages from './messages';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useMasqueradeData: jest.fn(), useIsMasquerading: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
})); }));
jest.mock('./SocialShareMenu', () => jest.fn(() => <div>SocialShareMenu</div>)); jest.mock('./SocialShareMenu', () => jest.fn(() => <div>SocialShareMenu</div>));
jest.mock('containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>)); jest.mock('containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>));
@@ -25,10 +23,6 @@ jest.mock('./hooks', () => ({
useOptionVisibility: jest.fn(), useOptionVisibility: jest.fn(),
})); }));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const props = { const props = {
cardId: 'test-card-id', cardId: 'test-card-id',
}; };
@@ -73,10 +67,14 @@ const mockHooks = (returnVals = {}) => {
}, },
{ isCardHook: true }, { isCardHook: true },
); );
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading }); mockHook(useIsMasquerading, !!returnVals.isMasquerading);
mockHook( mockHook(
reduxHooks.useCardEnrollmentData, useCourseData,
{ isEmailEnabled: !!returnVals.isEmailEnabled }, {
enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled,
},
},
{ isCardHook: true }, { isCardHook: true },
); );
}; };
@@ -91,13 +89,10 @@ describe('CourseCardMenu', () => {
}); });
it('initializes local hooks', () => { it('initializes local hooks', () => {
when(hooks.useEmailSettings).expectCalledWith(); 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 ', () => { it('initializes hook data ', () => {
when(reduxHooks.useMasqueradeData).expectCalledWith(); when(useIsMasquerading).expectCalledWith();
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); when(useCourseData).expectCalledWith(props.cardId);
}); });
}); });
describe('render', () => { describe('render', () => {

View File

@@ -2,15 +2,16 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import useActionDisabledState from './hooks'; import useActionDisabledState from './hooks';
const { courseTitleClicked } = track.course; const { courseTitleClicked } = track.course;
export const CourseCardTitle = ({ cardId }) => { export const CourseCardTitle = ({ cardId }) => {
const { courseName } = reduxHooks.useCardCourseData(cardId); const courseData = useCourseData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); const courseName = courseData?.course?.courseName;
const handleTitleClicked = reduxHooks.useTrackCourseEvent( const homeUrl = courseData?.courseRun?.homeUrl;
const handleTitleClicked = useCourseTrackingEvent(
courseTitleClicked, courseTitleClicked,
cardId, cardId,
homeUrl, homeUrl,

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { reduxHooks } from 'hooks'; import { useCourseData, useCourseTrackingEvent } from 'hooks';
import track from 'tracking'; import track from 'tracking';
import useActionDisabledState from './hooks'; import useActionDisabledState from './hooks';
import CourseCardTitle from './CourseCardTitle'; import CourseCardTitle from './CourseCardTitle';
@@ -12,19 +12,12 @@ jest.mock('tracking', () => ({
})); }));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardCourseData: jest.fn(), useCourseTrackingEvent: jest.fn(),
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
})); }));
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false }))); jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('CourseCardTitle', () => { describe('CourseCardTitle', () => {
const props = { const props = {
cardId: 'test-card-id', cardId: 'test-card-id',
@@ -36,9 +29,11 @@ describe('CourseCardTitle', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
reduxHooks.useCardCourseData.mockReturnValue({ courseName }); useCourseData.mockReturnValue({
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl }); course: { courseName },
reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick); courseRun: { homeUrl },
});
useCourseTrackingEvent.mockReturnValue(handleTitleClick);
}); });
it('renders course name as link when not disabled', async () => { it('renders course name as link when not disabled', async () => {
@@ -66,9 +61,8 @@ describe('CourseCardTitle', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
render(<CourseCardTitle {...props} />); render(<CourseCardTitle {...props} />);
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); expect(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.courseTitleClicked, track.course.courseTitleClicked,
props.cardId, props.cardId,
homeUrl, homeUrl,

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import messages from './messages'; import messages from './messages';
import * as module from './hooks'; import * as module from './hooks';
@@ -14,7 +14,8 @@ export const state = StrictDict({
export const useRelatedProgramsBadgeData = ({ cardId }) => { export const useRelatedProgramsBadgeData = ({ cardId }) => {
const [isOpen, setIsOpen] = module.state.isOpen(false); const [isOpen, setIsOpen] = module.state.isOpen(false);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const numPrograms = reduxHooks.useCardRelatedProgramsData(cardId).length; const courseData = useCourseData(cardId);
const numPrograms = courseData?.programs?.relatedPrograms?.length || 0;
let programsMessage = ''; let programsMessage = '';
if (numPrograms) { if (numPrograms) {
programsMessage = formatMessage( programsMessage = formatMessage(

View File

@@ -1,26 +1,24 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { MockUseState } from 'testUtils'; import { MockUseState } from 'testUtils';
import { reduxHooks } from 'hooks'; import { useCourseData } from 'hooks';
import * as hooks from './hooks'; import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { useCourseData: jest.fn(),
useCardRelatedProgramsData: jest.fn(),
},
})); }));
// jest.mock('@edx/frontend-platform/i18n', () => { jest.mock('@edx/frontend-platform/i18n', () => {
// const { formatMessage } = jest.requireActual('testUtils'); const { formatMessage } = jest.requireActual('testUtils');
// return { return {
// ...jest.requireActual('@edx/frontend-platform/i18n'), ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({ useIntl: () => ({
// formatMessage, formatMessage,
// }), }),
// }; };
// }); });
const cardId = 'test-card-id'; const cardId = 'test-card-id';
@@ -39,8 +37,10 @@ describe('RelatedProgramsBadge hooks', () => {
describe('useRelatedProgramsBadgeData', () => { describe('useRelatedProgramsBadgeData', () => {
beforeEach(() => { beforeEach(() => {
state.mock(); state.mock();
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ useCourseData.mockReturnValue({
length: numPrograms, programs: {
relatedPrograms: new Array(numPrograms).fill({}),
},
}); });
out = hooks.useRelatedProgramsBadgeData({ cardId }); out = hooks.useRelatedProgramsBadgeData({ cardId });
}); });
@@ -64,12 +64,12 @@ describe('RelatedProgramsBadge hooks', () => {
expect(out.numPrograms).toEqual(numPrograms); expect(out.numPrograms).toEqual(numPrograms);
}); });
test('returns empty programsMessage if no programs', () => { test('returns empty programsMessage if no programs', () => {
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 }); useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } });
out = hooks.useRelatedProgramsBadgeData({ cardId }); out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual(''); expect(out.programsMessage).toEqual('');
}); });
test('returns badgeLabelSingular programsMessage if 1 programs', () => { test('returns badgeLabelSingular programsMessage if 1 programs', () => {
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 }); useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } });
out = hooks.useRelatedProgramsBadgeData({ cardId }); out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual(formatMessage( expect(out.programsMessage).toEqual(formatMessage(
messages.badgeLabelSingular, messages.badgeLabelSingular,

View File

@@ -7,10 +7,6 @@ import RelatedProgramsBadge from '.';
jest.mock('containers/RelatedProgramsModal', () => 'RelatedProgramsModal'); jest.mock('containers/RelatedProgramsModal', () => 'RelatedProgramsModal');
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const hookProps = { const hookProps = {
isOpen: true, isOpen: true,
openModal: jest.fn().mockName('useRelatedProgramsBadge.openModal'), openModal: jest.fn().mockName('useRelatedProgramsBadge.openModal'),

View File

@@ -1,16 +1,19 @@
import { reduxHooks } from 'hooks'; import { useCourseData, useEntitlementInfo, useIsMasquerading } from 'hooks';
export const useActionDisabledState = (cardId) => { export const useActionDisabledState = (cardId) => {
const { isMasquerading } = reduxHooks.useMasqueradeData(); const courseData = useCourseData(cardId);
const isMasquerading = useIsMasquerading();
const { const {
hasAccess, isAudit, isAuditAccessExpired, isAudit, isAuditAccessExpired,
} = reduxHooks.useCardEnrollmentData(cardId); } = courseData.enrollment || {};
const { isStaff, hasUnmetPrereqs, isTooEarly } = courseData.enrollment?.coursewareAccess || {};
const hasAccess = isStaff || !(hasUnmetPrereqs || isTooEarly);
const { const {
isEntitlement, isFulfilled, canChange, hasSessions, isEntitlement, isFulfilled, canChange, hasSessions,
} = reduxHooks.useCardEntitlementData(cardId); } = useEntitlementInfo(courseData);
const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { resumeUrl, homeUrl } = courseData.courseRun || {};
const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired); const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired);

View File

@@ -1,14 +1,15 @@
import { reduxHooks } from 'hooks'; import { useCourseData, useIsMasquerading } from 'hooks';
import * as hooks from './hooks'; import * as hooks from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: jest.fn((fn) => fn()),
}));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { ...jest.requireActual('hooks'),
useMasqueradeData: jest.fn(), useCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(), useIsMasquerading: jest.fn(),
useCardEntitlementData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
})); }));
const cardId = 'my-test-course-number'; const cardId = 'my-test-course-number';
@@ -38,25 +39,38 @@ describe('useActionDisabledState', () => {
isAuditAccessExpired, isAuditAccessExpired,
resumeUrl, resumeUrl,
homeUrl, homeUrl,
availableSessions,
} = { ...defaultData, ...args }; } = { ...defaultData, ...args };
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); useIsMasquerading.mockReturnValue(isMasquerading);
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ useCourseData.mockReturnValue({
hasAccess, enrollment: {
isAudit, hasAccess,
isAuditAccessExpired, isAudit,
}); isAuditAccessExpired,
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ coursewareAccess: {
isEntitlement, isStaff: false,
isFulfilled, hasUnmetPrereqs: !hasAccess,
canChange, isTooEarly: !hasAccess,
hasSessions, },
}); },
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ entitlement: isEntitlement ? {
resumeUrl, isEntitlement: true,
homeUrl, isFulfilled,
canChange,
hasSessions,
availableSessions,
} : {},
courseRun: {
resumeUrl,
homeUrl,
},
}); });
}; };
beforeEach(() => {
jest.clearAllMocks();
});
const runHook = () => hooks.useActionDisabledState(cardId); const runHook = () => hooks.useActionDisabledState(cardId);
describe('disableBeginCourse', () => { describe('disableBeginCourse', () => {
const testDisabled = (data, expected) => { const testDisabled = (data, expected) => {
@@ -142,6 +156,7 @@ describe('useActionDisabledState', () => {
hasAccess: true, hasAccess: true,
canChange: true, canChange: true,
hasSessions: true, hasSessions: true,
availableSessions: ['session1'],
}, },
false, false,
); );

View File

@@ -1,23 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useWindowSize, breakpoints } from '@openedx/paragon'; import { useWindowSize, breakpoints } from '@openedx/paragon';
import { reduxHooks } from 'hooks';
export const useIsCollapsed = () => { export const useIsCollapsed = () => {
const { width } = useWindowSize(); const { width } = useWindowSize();
return width < breakpoints.small.maxWidth; 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;

View File

@@ -1,58 +1,32 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { renderHook } from '@testing-library/react';
import { useWindowSize } from '@openedx/paragon';
import { useIsCollapsed } from './hooks';
import { reduxHooks } from 'hooks'; jest.mock('@openedx/paragon', () => ({
useWindowSize: jest.fn(),
import * as hooks from './hooks'; breakpoints: {
small: {
jest.mock('hooks', () => ({ maxWidth: 576,
reduxHooks: { },
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
}, },
})); }));
// jest.mock('@edx/frontend-platform/i18n', () => { describe('useIsCollapsed', () => {
// const { formatMessage } = jest.requireActual('testUtils'); afterEach(() => {
// return {
// ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({
// formatMessage,
// }),
// };
// });
const cardId = 'my-test-course-number';
describe('CourseCard hooks', () => {
let out;
const { formatMessage } = useIntl();
beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('useCardData', () => { it('should return true when window width is smaller than small breakpoint', () => {
const courseData = { useWindowSize.mockReturnValue({ width: 500 });
title: 'fake-title', const { result } = renderHook(() => useIsCollapsed());
bannerImgSrc: 'my-banner-url', expect(result.current).toBe(true);
}; expect(useWindowSize).toHaveBeenCalled();
const runHook = ({ course = {} }) => { });
reduxHooks.useCardCourseData.mockReturnValueOnce({
...courseData, it('should return false when window width is larger than small breakpoint', () => {
...course, useWindowSize.mockReturnValue({ width: 800 });
}); const { result } = renderHook(() => useIsCollapsed());
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: 'test-is-enrolled' }); expect(result.current).toBe(false);
out = hooks.useCardData({ cardId }); expect(useWindowSize).toHaveBeenCalled();
};
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);
});
}); });
}); });

View File

@@ -24,10 +24,6 @@ jest.mock('./components/CourseCardActions', () => jest.fn(() => <div>CourseCardA
jest.mock('./components/CourseCardDetails', () => jest.fn(() => <div>CourseCardDetails</div>)); jest.mock('./components/CourseCardDetails', () => jest.fn(() => <div>CourseCardDetails</div>));
jest.mock('./components/CourseCardTitle', () => jest.fn(() => <div>CourseCardTitle</div>)); jest.mock('./components/CourseCardTitle', () => jest.fn(() => <div>CourseCardTitle</div>));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id'; const cardId = 'test-card-id';
describe('CourseCard component', () => { describe('CourseCard component', () => {

View File

@@ -1,27 +1,24 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Chip } from '@openedx/paragon'; import { Button, Chip } from '@openedx/paragon';
import { CloseSmall } from '@openedx/paragon/icons'; import { CloseSmall } from '@openedx/paragon/icons';
import { reduxHooks } from 'hooks'; import { useFilters } from 'data/context';
import messages from './messages'; import messages from './messages';
import './index.scss'; import './index.scss';
export const ActiveCourseFilters = ({ export const ActiveCourseFilters = () => {
filters,
handleRemoveFilter,
}) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const clearFilters = reduxHooks.useClearFilters(); const { filters, clearFilters, removeFilter } = useFilters();
return ( return (
<div id="course-list-active-filters"> <div id="course-list-active-filters">
{filters.map(filter => ( {filters.map(filter => (
<Chip <Chip
key={filter} key={filter}
iconAfter={CloseSmall} iconAfter={CloseSmall}
onClick={handleRemoveFilter(filter)} onClick={() => removeFilter(filter)}
> >
{formatMessage(messages[filter])} {formatMessage(messages[filter])}
</Chip> </Chip>
@@ -32,9 +29,5 @@ export const ActiveCourseFilters = ({
</div> </div>
); );
}; };
ActiveCourseFilters.propTypes = {
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
handleRemoveFilter: PropTypes.func.isRequired,
};
export default ActiveCourseFilters; export default ActiveCourseFilters;

View File

@@ -1,32 +1,54 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { useFilters } from 'data/context';
import { FilterKeys } from 'data/constants/app'; import { FilterKeys } from 'data/constants/app';
import userEvent from '@testing-library/user-event';
import ActiveCourseFilters from './ActiveCourseFilters'; import ActiveCourseFilters from './ActiveCourseFilters';
import messages from './messages'; import messages from './messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const filters = Object.values(FilterKeys); const filters = Object.values(FilterKeys);
jest.mock('data/context', () => ({
useFilters: jest.fn(),
}));
const removeFiltersMock = jest.fn().mockName('removeFilter');
const clearFiltersMock = jest.fn().mockName('clearFilters');
useFilters.mockReturnValue({
filters,
removeFilter: removeFiltersMock,
clearFilters: clearFiltersMock,
});
describe('ActiveCourseFilters', () => { describe('ActiveCourseFilters', () => {
const props = {
filters,
handleRemoveFilter: jest.fn().mockName('handleRemoveFilter'),
};
it('renders chips correctly', () => { it('renders chips correctly', () => {
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></IntlProvider>); render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
filters.map((key) => { filters.map((key) => {
const chip = screen.getByText(formatMessage(messages[key])); const chip = screen.getByText(formatMessage(messages[key]));
return expect(chip).toBeInTheDocument(); return expect(chip).toBeInTheDocument();
}); });
}); });
it('renders button correctly', () => { it('renders button correctly', () => {
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></IntlProvider>); render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
const button = screen.getByRole('button', { name: formatMessage(messages.clearAll) }); const button = screen.getByRole('button', { name: formatMessage(messages.clearAll) });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
}); });
it('should call onClick when button is clicked remove filter', async () => {
const user = userEvent.setup();
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
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(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
screen.debug();
const clearAllButton = screen.getByRole('button', { name: formatMessage(messages.clearAll) });
await user.click(clearAllButton);
expect(clearFiltersMock).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { import {
Button, Button,
Form, Form,
@@ -14,44 +13,51 @@ import {
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Close, Tune } from '@openedx/paragon/icons'; import { Close, Tune } from '@openedx/paragon/icons';
import { reduxHooks } from 'hooks'; import { useInitializeLearnerHome } from 'data/hooks';
import { useFilters } from 'data/context';
import FilterForm from './components/FilterForm'; import FilterForm from './components/FilterForm';
import SortForm from './components/SortForm'; import SortForm from './components/SortForm';
import useCourseFilterControlsData from './hooks';
import messages from './messages'; import messages from './messages';
import './index.scss'; import './index.scss';
export const CourseFilterControls = ({ export const CourseFilterControls = () => {
sortBy, const [isOpen, setIsOpen] = React.useState(false);
setSortBy, const [targetRef, setTargetRef] = React.useState(null);
filters,
}) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const hasCourses = reduxHooks.useHasCourses(); const { data } = useInitializeLearnerHome();
const hasCourses = React.useMemo(() => data?.courses?.length > 0, [data]);
const { const {
isOpen, filters, sortBy, setSortBy, addFilter, removeFilter,
open, } = useFilters();
close,
target, const openFiltersOptions = () => {
setTarget, track.filter.filterClicked();
handleFilterChange, setIsOpen(true);
handleSortChange, };
} = useCourseFilterControlsData({ const closeFiltersOptions = () => {
filters, track.filter.filterOptionSelected(filters);
setSortBy, 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 { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth; const isMobile = width < breakpoints.small.minWidth;
return ( return (
<div id="course-filter-controls"> <div id="course-filter-controls">
<Button <Button
ref={setTarget} ref={setTargetRef}
variant="outline-primary" variant="outline-primary"
iconBefore={Tune} iconBefore={Tune}
onClick={open} onClick={openFiltersOptions}
disabled={!hasCourses} disabled={!hasCourses}
> >
{formatMessage(messages.refine)} {formatMessage(messages.refine)}
@@ -63,7 +69,7 @@ export const CourseFilterControls = ({
className="w-75" className="w-75"
position="left" position="left"
show={isOpen} show={isOpen}
onClose={close} onClose={closeFiltersOptions}
> >
<div className="p-1 mr-3"> <div className="p-1 mr-3">
<b>{formatMessage(messages.refine)}</b> <b>{formatMessage(messages.refine)}</b>
@@ -76,16 +82,16 @@ export const CourseFilterControls = ({
<SortForm {...{ sortBy, handleSortChange }} /> <SortForm {...{ sortBy, handleSortChange }} />
</div> </div>
<div className="pgn__modal-close-container"> <div className="pgn__modal-close-container">
<ModalCloseButton variant="tertiary" onClick={close}> <ModalCloseButton variant="tertiary" onClick={closeFiltersOptions}>
<Icon src={Close} /> <Icon src={Close} />
</ModalCloseButton> </ModalCloseButton>
</div> </div>
</Sheet> </Sheet>
) : ( ) : (
<ModalPopup <ModalPopup
positionRef={target} positionRef={targetRef}
isOpen={isOpen} isOpen={isOpen}
onClose={close} onClose={closeFiltersOptions}
placement="bottom-end" placement="bottom-end"
> >
<div <div
@@ -106,10 +112,5 @@ export const CourseFilterControls = ({
</div> </div>
); );
}; };
CourseFilterControls.propTypes = {
sortBy: PropTypes.string.isRequired,
setSortBy: PropTypes.func.isRequired,
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export default CourseFilterControls; export default CourseFilterControls;

View File

@@ -1,79 +1,150 @@
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { breakpoints, useWindowSize } from '@openedx/paragon'; import { breakpoints, useWindowSize } from '@openedx/paragon';
import { reduxHooks } from 'hooks';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { FilterKeys, SortKeys } from 'data/constants/app'; import { FilterKeys, SortKeys } from 'data/constants/app';
import { useInitializeLearnerHome } from 'data/hooks';
import { useFilters } from 'data/context';
import userEvent from '@testing-library/user-event';
import messages from './messages'; import messages from './messages';
import CourseFilterControls from './CourseFilterControls'; import CourseFilterControls from './CourseFilterControls';
import useCourseFilterControlsData from './hooks';
jest.mock('hooks', () => ({ jest.mock('data/hooks', () => ({
reduxHooks: { useHasCourses: jest.fn() }, useInitializeLearnerHome: jest.fn().mockReturnValue({ data: { courses: [1, 2, 3] } }),
})); }));
jest.mock('./hooks', () => jest.fn());
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
jest.mock('@openedx/paragon', () => ({ jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'), ...jest.requireActual('@openedx/paragon'),
useWindowSize: jest.fn(), useWindowSize: jest.fn(),
})); }));
jest.mock('tracking', () => ({
filter: {
filterClicked: jest.fn().mockName('segment.filterClicked'),
filterOptionSelected: jest.fn().mockName('segment.filterOptionSelected'),
},
}));
const filters = Object.values(FilterKeys); const filters = Object.values(FilterKeys);
const mockControlsData = { jest.mock('data/context', () => ({
isOpen: false, useFilters: jest.fn(),
open: jest.fn().mockName('open'), }));
close: jest.fn().mockName('close'),
target: 'target-test', const setSortByMock = jest.fn().mockName('setSortBy');
setTarget: jest.fn(), useFilters.mockReturnValue({
handleFilterChange: jest.fn().mockName('handleFilterChange'), filters,
handleSortChange: jest.fn().mockName('handleSortChange'), removeFilter: jest.fn().mockName('removeFilter'),
}; clearFilters: jest.fn().mockName('clearFilters'),
setSortBy: setSortByMock,
addFilter: jest.fn().mockName('addFilter'),
});
describe('CourseFilterControls', () => { describe('CourseFilterControls', () => {
const props = {
sortBy: SortKeys.enrolled,
setSortBy: jest.fn().mockName('setSortBy'),
filters,
};
describe('mobile and open', () => { describe('mobile and open', () => {
it('should render sheet', () => { it('should render sheet', async () => {
reduxHooks.useHasCourses.mockReturnValue(true); const user = userEvent.setup();
useCourseFilterControlsData.mockReturnValue({ ...mockControlsData, isOpen: true }); useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth - 1 });
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth - 1 }); render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>); const filtersButton = screen.getByRole('button', { name: 'Refine' });
const sheet = screen.getByRole('presentation', { hidden: true }); await user.click(filtersButton);
expect(sheet).toBeInTheDocument(); await waitFor(() => {
expect(sheet.parentElement).toHaveClass('sheet-container'); const sheet = screen.getByRole('presentation', { hidden: true });
expect(sheet).toBeInTheDocument();
expect(sheet.parentElement).toHaveClass('sheet-container');
});
}); });
}); });
describe('is not mobile', () => { describe('is not mobile', () => {
it('should have button disabled', () => { it('should have button disabled', async () => {
reduxHooks.useHasCourses.mockReturnValue(true); const user = userEvent.setup();
useCourseFilterControlsData.mockReturnValue({ ...mockControlsData, isOpen: true }); useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth }); render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>); const filtersButton = screen.getByRole('button', { name: 'Refine' });
const filterForm = screen.getByText(messages.courseStatus.defaultMessage); await user.click(filtersButton);
const modal = filterForm.closest('div.pgn__modal-popup__tooltip'); await waitFor(() => {
expect(modal).toBeInTheDocument(); const filterForm = screen.getByText(messages.courseStatus.defaultMessage);
const modal = filterForm.closest('div.pgn__modal-popup__tooltip');
expect(modal).toBeInTheDocument();
});
}); });
}); });
describe('no courses', () => { describe('no courses', () => {
it('should have button disabled', () => { it('should have button disabled', () => {
reduxHooks.useHasCourses.mockReturnValue(false); useInitializeLearnerHome.mockReturnValue({ data: { courses: [] } });
useCourseFilterControlsData.mockReturnValue(mockControlsData);
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth }); useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>); render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
const button = screen.getByRole('button', { name: formatMessage(messages.refine) }); const button = screen.getByRole('button', { name: formatMessage(messages.refine) });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
expect(button).toBeDisabled(); expect(button).toBeDisabled();
}); });
}); });
describe('with courses', () => {
it('should have button enabled', () => {
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
const button = screen.getByRole('button', { name: formatMessage(messages.refine) });
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
});
it('should call setSortBy on sort change', async () => {
const user = userEvent.setup();
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
const filtersButton = screen.getByRole('button', { name: 'Refine' });
await user.click(filtersButton);
await waitFor(async () => {
const sortRadio = screen.getByRole('radio', { name: formatMessage(messages.sortTitle) });
await user.click(sortRadio);
expect(setSortByMock).toHaveBeenCalledWith(SortKeys.title);
});
});
it('should call addFilter on filter check', async () => {
const user = userEvent.setup();
const addFilterMock = jest.fn().mockName('addFilter');
const removeFilterMock = jest.fn().mockName('removeFilter');
useFilters.mockReturnValue({
filters: [],
removeFilter: removeFilterMock,
clearFilters: jest.fn().mockName('clearFilters'),
setSortBy: jest.fn().mockName('setSortBy'),
addFilter: addFilterMock,
});
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
const filtersButton = screen.getByRole('button', { name: 'Refine' });
await user.click(filtersButton);
await waitFor(async () => {
const filterCheckbox = screen.getByText('In-Progress');
await user.click(filterCheckbox);
expect(addFilterMock).toHaveBeenCalledWith(FilterKeys.inProgress);
});
});
it('should call removeFilter on filter uncheck', async () => {
const user = userEvent.setup();
const addFilterMock = jest.fn().mockName('addFilter');
const removeFilterMock = jest.fn().mockName('removeFilter');
useFilters.mockReturnValue({
filters: [FilterKeys.inProgress],
removeFilter: removeFilterMock,
clearFilters: jest.fn().mockName('clearFilters'),
setSortBy: jest.fn().mockName('setSortBy'),
addFilter: addFilterMock,
});
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
const filtersButton = screen.getByRole('button', { name: 'Refine' });
await user.click(filtersButton);
await waitFor(async () => {
const filterCheckbox = screen.getByText('In-Progress');
await user.click(filterCheckbox);
expect(removeFilterMock).toHaveBeenCalledWith(FilterKeys.inProgress);
});
});
});
}); });

View File

@@ -6,10 +6,6 @@ import { FilterKeys } from 'data/constants/app';
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
import messages from '../messages'; import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('Checkbox', () => { describe('Checkbox', () => {
describe('renders correctly', () => { describe('renders correctly', () => {
Object.keys(FilterKeys).forEach((filterKey) => { Object.keys(FilterKeys).forEach((filterKey) => {

View File

@@ -5,10 +5,6 @@ import { FilterKeys } from 'data/constants/app';
import { FilterForm, filterOrder } from './FilterForm'; import { FilterForm, filterOrder } from './FilterForm';
import messages from '../messages'; import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const mockHandleFilterChange = jest.fn(); const mockHandleFilterChange = jest.fn();
const defaultProps = { const defaultProps = {

View File

@@ -6,10 +6,6 @@ import { SortKeys } from 'data/constants/app';
import SortForm from './SortForm'; import SortForm from './SortForm';
import messages from '../messages'; import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('SortForm', () => { describe('SortForm', () => {
const props = { const props = {
handleSortChange: jest.fn().mockName('handleSortChange'), handleSortChange: jest.fn().mockName('handleSortChange'),

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { useToggle } from '@openedx/paragon';
import { StrictDict } from 'utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import * as module from './hooks';
export const state = StrictDict({
target: (val) => React.useState(val), // eslint-disable-line
});
/**
* Sets up a toggle for the modal as well as helper functions for handling changes to the form controls.
*
* @param {array} filters Currently active course filters
* @param {function} setSortBy Set function for sorting the course list
* @returns {object} data and functions for managing the CourseFilterControls component
*/
export const useCourseFilterControlsData = ({
filters,
setSortBy,
}) => {
const [isOpen, toggleOpen, toggleClose] = useToggle(false);
const [target, setTarget] = module.state.target(null);
const addFilter = reduxHooks.useAddFilter();
const removeFilter = reduxHooks.useRemoveFilter();
const handleFilterChange = ({ target: { checked, value } }) => {
const update = checked ? addFilter : removeFilter;
update(value);
};
const handleSortChange = ({ target: { value } }) => {
setSortBy(value);
};
const open = () => {
track.filter.filterClicked();
toggleOpen();
};
const close = () => {
track.filter.filterOptionSelected(filters);
toggleClose();
};
return {
isOpen,
open,
close,
target,
setTarget,
handleFilterChange,
handleSortChange,
};
};
export default useCourseFilterControlsData;

View File

@@ -1,122 +0,0 @@
import { useToggle } from '@openedx/paragon';
import { MockUseState } from 'testUtils';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import * as hooks from './hooks';
// jest.mock('@openedx/paragon', () => ({
// ...jest.requireActual('@openedx/paragon'),
// useToggle: jest.fn().mockImplementation((val) => [
// val,
// jest.fn().mockName('useToggle.setTrue'),
// jest.fn().mockName('useToggle.setFalse'),
// ]),
// }));
jest.mock('tracking', () => ({
filter: {
filterClicked: jest.fn(),
filterOptionSelected: jest.fn(),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useAddFilter: jest.fn(),
useRemoveFilter: jest.fn(),
},
}));
const state = new MockUseState(hooks);
describe('CourseFilterControls hooks', () => {
let out;
const filters = ['a', 'b', 'c'];
const setSortBy = jest.fn();
const removeFilter = jest.fn();
reduxHooks.useRemoveFilter.mockReturnValue(removeFilter);
const addFilter = jest.fn();
reduxHooks.useAddFilter.mockReturnValue(addFilter);
const toggleOpen = jest.fn();
const toggleClose = jest.fn();
describe('state values', () => {
state.testGetter(state.keys.target);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCourseFilterControlsData', () => {
beforeEach(() => {
useToggle.mockReturnValueOnce([false, toggleOpen, toggleClose]);
state.mock();
out = hooks.useCourseFilterControlsData({
filters,
setSortBy,
});
});
afterEach(state.restore);
test('default state', () => {
expect(out.isOpen).toEqual(false);
expect(out.target).toEqual(state.stateVals.target);
});
test('open calls toggleOpen and track.filter.filterClicked', () => {
out.open();
expect(toggleOpen).toHaveBeenCalled();
expect(track.filter.filterClicked).toHaveBeenCalled();
});
test('close calls toggleClose and track.filter.filterOptionSelected', () => {
out.close();
expect(toggleClose).toHaveBeenCalled();
expect(track.filter.filterOptionSelected).toHaveBeenCalledWith(filters);
});
test('isOpen is true when target is set', () => {
useToggle.mockReturnValueOnce([true, toggleOpen, toggleClose]);
expect(out.target).toEqual(null);
state.mockVal(state.keys.target, 'foo');
out = hooks.useCourseFilterControlsData({
filters,
setSortBy,
});
expect(out.isOpen).toEqual(true);
expect(out.target).toEqual('foo');
});
test('handle filter change', () => {
const value = 'a';
out.handleFilterChange({
target: {
checked: true,
value,
},
});
expect(addFilter).toHaveBeenCalledWith(value);
out.handleFilterChange({
target: {
checked: false,
value,
},
});
expect(removeFilter).toHaveBeenCalledWith(value);
});
test('handle sort change', () => {
const value = 'a';
out.handleSortChange({
target: {
value,
},
});
expect(setSortBy).toHaveBeenCalledWith(value);
});
});
});

View File

@@ -11,14 +11,15 @@ import { useIsCollapsed } from './hooks';
export const CourseList = ({ courseListData }) => { export const CourseList = ({ courseListData }) => {
const { const {
filterOptions, setPageNumber, numPages, showFilters, visibleList, setPageNumber, numPages, visibleList, showFilters,
} = courseListData; } = courseListData;
const isCollapsed = useIsCollapsed(); const isCollapsed = useIsCollapsed();
return ( return (
<> <>
{showFilters && ( {showFilters && (
<div id="course-list-active-filters-container"> <div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} /> <ActiveCourseFilters />
</div> </div>
)} )}
<div className="d-flex flex-column flex-grow-1"> <div className="d-flex flex-column flex-grow-1">
@@ -42,7 +43,6 @@ export const CourseList = ({ courseListData }) => {
export const courseListDataShape = PropTypes.shape({ export const courseListDataShape = PropTypes.shape({
showFilters: PropTypes.bool.isRequired, showFilters: PropTypes.bool.isRequired,
visibleList: PropTypes.arrayOf(PropTypes.shape()).isRequired, visibleList: PropTypes.arrayOf(PropTypes.shape()).isRequired,
filterOptions: PropTypes.shape().isRequired,
numPages: PropTypes.number.isRequired, numPages: PropTypes.number.isRequired,
setPageNumber: PropTypes.func.isRequired, setPageNumber: PropTypes.func.isRequired,
}); });

Some files were not shown because too many files have changed in this diff Show More