Compare commits

..

97 Commits

Author SHA1 Message Date
renovate[bot]
9981e5ff32 chore(deps): update dependency copy-webpack-plugin to v14 2026-03-09 08:36:06 +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
Diana Villalvazo
fdc58a671a test: Transform snapshots into rtl tests (#674) 2025-06-30 15:35:13 -04:00
Muhammad Noyan Aziz
90aa652ca6 feat: added a generic creditPurchase Url logic (#675)
Co-authored-by: Muhammad Noyan  Aziz <noyan.aziz@A006-01474.local>
2025-06-30 10:35:32 -04:00
renovate[bot]
05a08101a2 fix(deps): update dependency @openedx/paragon to v23.13.0 (#677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 10:17:18 +00:00
renovate[bot]
cf94ea99b4 chore(deps): update dependency @openedx/frontend-build to v14.6.1 (#676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 04:54:58 +00:00
Diana Villalvazo
7f1e509ecf test: Deprecate react-unit-test-utils 12/15 (#671) 2025-06-27 12:13:33 -04:00
Diana Villalvazo
31c5445722 test: Deprecate react-unit-test-utils 11/15 (#670) 2025-06-26 15:53:29 -04:00
Diana Villalvazo
2acf7fbd73 test: Deprecate react-unit-test-utils 10/15 (#658) 2025-06-25 08:22:38 -04:00
Diana Villalvazo
cae7b1bba0 test: Deprecate react-unit-test-utils 9/15 (#668) 2025-06-24 16:50:11 +00:00
Diana Villalvazo
6ae8180f99 test: Deprecate react-unit-test-utils 8/15 (#664) 2025-06-24 12:38:13 -04:00
Diana Villalvazo
ab0f139d75 test: Deprecate react-unit-test-utils 7/15 (#662) 2025-06-24 09:44:25 -04:00
Diana Villalvazo
8a0b9dca5d test: Deprecate react-unit-test-utils 6/15 (#660) 2025-06-23 14:22:21 -04:00
Brian Smith
2e59e24876 feat!: add design tokens support (#665)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
2025-06-18 15:05:17 -04:00
Diana Villalvazo
75865290bf test: Deprecate react-unit-test-utils 5/15 (#659) 2025-06-17 20:32:59 -04:00
Diana Villalvazo
57fe161b16 test: Deprecate react-unit-test-utils 4/15 (#656) 2025-06-17 20:09:36 -04:00
Diana Villalvazo
5c4dfd5de3 test: Deprecate react-unit-test-utils 3/15 (#655) 2025-06-17 20:00:44 -04:00
Diana Villalvazo
b0daefa2bf test: Deprecate react-unit-test-utils 2/15 (#643) 2025-06-17 19:47:36 -04:00
Diana Villalvazo
42a21180c7 test: Deprecate react-unit-test-utils 1/15 (#640) 2025-06-17 12:52:56 -04:00
356 changed files with 16716 additions and 10286 deletions

10
.dockerignore Executable file
View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log
README.md
LICENSE
.babelrc
.eslintignore
.eslintrc.json
.gitignore
.npmignore
commitlint.config.js

47
.env Normal file
View File

@@ -0,0 +1,47 @@
NODE_ENV='production'
APP_ID='learner-dashboard'
NODE_PATH=./src
BASE_URL=''
LMS_BASE_URL=''
ECOMMERCE_BASE_URL=''
LOGIN_URL=''
LOGOUT_URL=''
CSRF_TOKEN_API_PATH=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
DATA_API_BASE_URL=''
SEGMENT_KEY=''
FEATURE_FLAGS={}
ACCESS_TOKEN_COOKIE_NAME=''
NEW_RELIC_APP_ID=''
NEW_RELIC_LICENSE_KEY=''
SITE_NAME=''
MARKETING_SITE_BASE_URL=''
SUPPORT_URL=''
CONTACT_URL=''
OPEN_SOURCE_URL=''
TERMS_OF_SERVICE_URL=''
PRIVACY_POLICY_URL=''
FACEBOOK_URL=''
TWITTER_URL=''
YOU_TUBE_URL=''
LINKED_IN_URL=''
REDDIT_URL=''
APPLE_APP_STORE_URL=''
GOOGLE_PLAY_URL=''
ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
LEARNING_BASE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}

53
.env.development Normal file
View File

@@ -0,0 +1,53 @@
NODE_ENV='development'
APP_ID='learner-dashboard'
PORT=1996
BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000'
ECOMMERCE_BASE_URL='http://localhost:18130'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL=''
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
FACEBOOK_URL='https://www.facebook.com'
TWITTER_URL='https://twitter.com'
YOU_TUBE_URL='https://www.youtube.com'
LINKED_IN_URL='https://www.linkedin.com'
REDDIT_URL='https://www.reddit.com'
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
GOOGLE_PLAY_URL='https://play.google.com/store'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
SESSION_COOKIE_DOMAIN='localhost'
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995'
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}

51
.env.test Normal file
View File

@@ -0,0 +1,51 @@
NODE_ENV='test'
APP_ID='learner-dashboard'
PORT=1996
BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000'
ECOMMERCE_BASE_URL='http://localhost:18130'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL=''
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
FACEBOOK_URL='https://www.facebook.com'
TWITTER_URL='https://twitter.com'
YOU_TUBE_URL='https://www.youtube.com'
LINKED_IN_URL='https://www.linkedin.com'
REDDIT_URL='https://www.reddit.com'
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
GOOGLE_PLAY_URL='https://play.google.com/store'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
HOTJAR_APP_ID='hot-jar-app-id'
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
ACCOUNT_PROFILE_URL='http://account-profile-url.test'
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=true
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
PARAGON_THEME_URLS={}

5
.eslintignore Executable file
View File

@@ -0,0 +1,5 @@
coverage/*
dist/
node_modules/
src/postcss.config.js
src/segment.js

22
.eslintrc.js Normal file
View File

@@ -0,0 +1,22 @@
const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('eslint', {
rules: {
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'import/no-import-module-exports': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
},
});
config.settings = {
"import/resolver": {
node: {
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
},
},
};
module.exports = config;

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.snap linguist-generated=false

1
.github/CODEOWNERS vendored
View File

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

View File

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

30
.gitignore vendored
View File

@@ -1,15 +1,29 @@
.DS_Store
.eslintcache
env.config.*
node_modules
npm-debug.log
coverage
module.config.js
dist/
/*.tgz
public/samples/
### i18n ###
src/i18n/transifex_input.json
### pyenv ###
.python-version
### Editors ###
.DS_Store
### Emacs ###
*~
/temp
/.vscode
*.swo
*.swp
### Development environments ###
.idea
.vscode
# Local package dependencies
module.config.js
### transifex ###
src/i18n/transifex_input.json
temp
src/i18n/messages

View File

@@ -1,6 +1,12 @@
__mocks__
.eslintignore
.eslintrc.json
.gitignore
docker-compose.yml
Dockerfile
Makefile
npm-debug.log
config
coverage
node_modules
*.test.js
*.test.jsx
*.test.ts
*.test.tsx
public

View File

@@ -12,6 +12,11 @@ transifex_temp = ./temp/babel-plugin-formatjs
NPM_TESTS=build i18n_extract lint test
# Variables for additional translation sources and imports (define in edx-internal if needed)
ATLAS_EXTRA_SOURCES ?=
ATLAS_EXTRA_INTL_IMPORTS ?=
ATLAS_OPTIONS ?=
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -24,19 +29,6 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
requirements: ## install ci requirements
npm ci
clean:
rm -rf dist
build: clean
tsc --project tsconfig.build.json
tsc-alias -p tsconfig.build.json
find src -type f \( -name '*.scss' -o -name '*.png' -o -name '*.svg' \) -exec sh -c '\
for f in "$$@"; do \
d="dist/$${f#src/}"; \
mkdir -p "$$(dirname "$$d")"; \
cp "$$f" "$$d"; \
done' sh {} +
i18n.extract:
# Pulling display strings from .jsx files into .json files...
rm -rf $(transifex_temp)
@@ -58,11 +50,14 @@ pull_translations:
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-base/src/i18n/messages:frontend-base \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard \
$(ATLAS_EXTRA_SOURCES)
$(intl_imports) frontend-base paragon frontend-app-learner-dashboard
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS)
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

View File

@@ -18,7 +18,7 @@ frontend-app-learner-dashboard
The Learner Home app is a microfrontend (MFE) course listing experience for the Open edX Learning Management System
(LMS). This experience was designed to provide a clean and functional interface to allow learners to view all of their
open enrollments, as well as take relevant actions on those enrollments. It also serves as host to a number of exposed
"widget" containers to provide upsell and discovery widgets as sidebar components.
"widget" containers to provide upsell and discovery widgets as sidebar/footer components.
Quickstart
----------
@@ -30,10 +30,21 @@ To start the MFE and enable the feature in LMS:
From there, simply load the configured address/port. You should be prompted to log into your LMS if you are not
already, and then redirected to your home page.
Widgets
Plugins
-------
This MFE can be customized with widgets. The parts of this MFE that can be customized in that manner are documented
`here </src/slots>`_.
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
Contributing
------------
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.
This project is currently accepting all types of contributions — bug fixes, security fixes, maintenance work, or new features.
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>`_.
When proposing a change, create an issue in this repo to get the discussion started.
License
-------

10
app.d.ts vendored
View File

@@ -1,10 +0,0 @@
/// <reference types="@openedx/frontend-base" />
declare module 'site.config' {
export default SiteConfig;
}
declare module '*.svg' {
const content: string;
export default content;
}

View File

@@ -1,3 +0,0 @@
const { createConfig } = require('@openedx/frontend-base/tools');
module.exports = createConfig('babel');

View File

@@ -1,22 +0,0 @@
// @ts-check
const { createLintConfig } = require('@openedx/frontend-base/tools');
module.exports = createLintConfig(
{
files: [
'src/**/*',
'site.config.*',
],
},
{
ignores: [
'coverage/*',
'dist/*',
'documentation/*',
'node_modules/*',
'**/__mocks__/*',
'**/__snapshots__/*',
],
},
);

74
example.env.config.js Normal file
View File

@@ -0,0 +1,74 @@
/*
Learner Dashboard is now able to handle JS-based configuration!
For the time being, the `.env.*` files are still made available when cloning down this repo or pulling from
the master branch. To switch to using `env.config.js`, make a copy of `example.env.config.js` and configure as needed.
For testing with Jest Snapshot, there is a mock in `/src/setupTest.jsx` for `getConfig` that will need to be
uncommented.
Note: having both .env and env.config.js files will follow a predictable order, in which non-empty values in the
JS-based config will overwrite the .env environment variables.
frontend-platform's getConfig loads configuration in the following sequence:
- .env file config
- optional handlers (commonly used to merge MFE-specific config in via additional process.env variables)
- env.config.js file config
- runtime config
*/
module.exports = {
NODE_ENV: 'development',
APP_ID: 'learner-dashboard',
NODE_PATH: './src',
PORT: 1996,
BASE_URL: 'localhost:1996',
LMS_BASE_URL: 'http://localhost:18000',
ECOMMERCE_BASE_URL: 'http://localhost:18130',
CREDIT_PURCHASE_URL: 'http://localhost:8140',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/logout',
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
USER_INFO_COOKIE_NAME: 'edx-user-info',
SITE_NAME: 'localhost',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application in your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
SEGMENT_KEY: '',
FEATURE_FLAGS: {},
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
ENTERPRISE_MARKETING_URL: 'http://example.com',
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
LEARNING_BASE_URL: 'http://localhost:2000',
SESSION_COOKIE_DOMAIN: 'localhost',
HOTJAR_APP_ID: '',
HOTJAR_VERSION: 6,
HOTJAR_DEBUG: '',
NEW_RELIC_APP_ID: '',
NEW_RELIC_LICENSE_KEY: '',
ACCOUNT_SETTINGS_URL: 'http://localhost:1997',
ACCOUNT_PROFILE_URL: 'http://localhost:1995',
CAREER_LINK_URL: '',
EXPERIMENT_08_23_VAN_PAINTED_DOOR: true,
SHOW_UNENROLL_SURVEY: true
};

View File

@@ -1,22 +1,18 @@
const { createConfig } = require('@openedx/frontend-base/tools');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('test', {
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'jest-expect-message',
'<rootDir>/src/setupTest.jsx',
],
modulePaths: ['<rootDir>/src/'],
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
'src/data/services/lms/fakeData', // don't unit test mock data
'src/test', // don't unit test integration test utils
'src/__mocks__',
],
moduleNameMapper: {
// Asset mocks
'\\.svg$': '<rootDir>/src/__mocks__/svg.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/src/__mocks__/file.js',
},
testTimeout: 120000,
testEnvironment: 'jsdom',
});

8498
package-lock.json generated

File diff suppressed because it is too large Load Diff

91
package.json Normal file → Executable file
View File

@@ -1,90 +1,77 @@
{
"name": "@openedx/frontend-app-learner-dashboard",
"version": "1.0.0-alpha.6",
"name": "@edx/frontend-app-learner-dashboard",
"version": "0.0.1",
"description": "",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-learner-dashboard.git"
},
"exports": {
".": "./dist/index.js",
"./app.scss": "./dist/app.scss"
},
"files": [
"/dist"
],
"browserslist": [
"extends @edx/browserslist-config"
],
"sideEffects": [
"*.css",
"*.scss"
],
"scripts": {
"build": "make build",
"clean": "make clean",
"dev": "PORT=1996 PUBLIC_PATH=/learner-dashboard openedx dev",
"i18n_extract": "openedx formatjs extract",
"lint": "openedx lint .",
"lint:fix": "openedx lint --fix .",
"prepack": "npm run build",
"test": "openedx test --coverage --passWithNoTests"
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"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",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"types": "tsc --noEmit"
},
"author": "Open edX",
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-learner-dashboard#readme",
"homepage": "",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-learner-dashboard/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@tanstack/react-query": "^5.90.16",
"classnames": "^2.3.1",
"filesize": "^10.0.0",
"core-js": "3.48.0",
"font-awesome": "4.7.0",
"history": "5.3.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"prop-types": "15.8.1",
"react-share": "^4.4.0",
"redux-logger": "3.0.6",
"redux-thunk": "2.4.2",
"reselect": "^4.0.0"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-intl": "6.8.9",
"react-router-dom": "6.30.3",
"react-share": "^5.2.2",
"regenerator-runtime": "^0.14.0",
"util": "^0.12.4"
},
"devDependencies": {
"@edx/browserslist-config": "^1.5.0",
"@edx/browserslist-config": "^1.3.0",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"copy-webpack-plugin": "^14.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jest-when": "^3.6.0",
"react-dev-utils": "^12.0.0",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4",
"tsc-alias": "^1.8.16"
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0-alpha.13",
"@openedx/paragon": "^23",
"@tanstack/react-query": "^5",
"@types/react": "^18",
"@types/react-dom": "^18",
"react": "^18",
"react-dom": "^18",
"react-redux": "^8",
"react-router": "^6",
"react-router-dom": "^6",
"redux": "^4"
"react-test-renderer": "^18.3.1"
}
}

View File

@@ -1,7 +1,6 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>Learner Dashboard Development Site></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -1,40 +0,0 @@
import { EnvironmentTypes, SiteConfig, footerApp, headerApp, shellApp } from '@openedx/frontend-base';
import { learnerDashboardApp } from './src';
import './src/app.scss';
const siteConfig: SiteConfig = {
siteId: 'learner-dashboard-dev',
siteName: 'Learner Dashboard Dev',
baseUrl: 'http://apps.local.openedx.io:1996',
lmsBaseUrl: 'http://local.openedx.io:8000',
loginUrl: 'http://local.openedx.io:8000/login',
logoutUrl: 'http://local.openedx.io:8000/logout',
environment: EnvironmentTypes.DEVELOPMENT,
apps: [
shellApp,
headerApp,
footerApp,
learnerDashboardApp
],
externalRoutes: [
{
role: 'org.openedx.frontend.role.profile',
url: 'http://apps.local.openedx.io:1995/profile/'
},
{
role: 'org.openedx.frontend.role.account',
url: 'http://apps.local.openedx.io:1997/account/'
},
{
role: 'org.openedx.frontend.role.logout',
url: 'http://local.openedx.io:8000/logout'
},
],
accessTokenCookieName: 'edx-jwt-cookie-header-payload',
};
export default siteConfig;

View File

@@ -1,27 +0,0 @@
import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base';
import { appId } from './src/constants';
const siteConfig: SiteConfig = {
siteId: 'learner-dashboard-test-site',
siteName: 'Learner Dashboard Test Site',
baseUrl: 'http://localhost:1996',
lmsBaseUrl: 'http://localhost:8000',
loginUrl: 'http://localhost:8000/login',
logoutUrl: 'http://localhost:8000/logout',
environment: EnvironmentTypes.TEST,
apps: [{
appId,
config: {
ECOMMERCE_BASE_URL: 'http://localhost:18130',
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
LEARNING_BASE_URL: 'http://localhost:2000',
},
}],
accessTokenCookieName: 'edx-jwt-cookie-header-payload',
segmentKey: '',
};
export default siteConfig;

70
src/App.jsx Executable file
View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import Dashboard from 'containers/Dashboard';
import AppWrapper from 'containers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { useMasquerade } from 'data/context';
import messages from './messages';
import './App.scss';
export const App = () => {
const { formatMessage } = useIntl();
const { masqueradeUser } = useMasquerade();
const { data, isError } = useInitializeLearnerHome();
const hasNetworkFailure = !masqueradeUser && isError;
const supportEmail = data?.platformSettings?.supportEmail || undefined;
/* istanbul ignore next */
React.useEffect(() => {
if (getConfig().HOTJAR_APP_ID) {
try {
initializeHotjar({
hotjarId: getConfig().HOTJAR_APP_ID,
hotjarVersion: getConfig().HOTJAR_VERSION,
hotjarDebug: !!getConfig().HOTJAR_DEBUG,
});
} catch (error) {
logError(error);
}
}
}, []);
return (
<>
<Helmet>
<title>{formatMessage(messages.pageTitle)}</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main id="main">
{hasNetworkFailure
? (
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (
<Dashboard />
)}
</main>
</AppWrapper>
<FooterSlot />
</div>
</>
);
};
export default App;

66
src/App.scss Executable file
View File

@@ -0,0 +1,66 @@
// frontend-app-*/src/index.scss
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$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-footer/dist/_footer";
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert.alert-info .alert-icon {
color: black;
}
#root {
// Removing a odd 1.5 scaling on checkboxes.:
input[type=checkbox] {
transform: none;
}
display: flex;
flex-direction: column;
min-height: 100vh;
main {
flex-grow: 1;
}
header {
flex: 0 0 auto;
.logo {
display: block;
box-sizing: content-box;
position: relative;
top: 0.1em;
height: 1.75rem;
margin-right: 1rem;
img {
display: block;
height: 100%;
}
}
}
footer {
flex: 0;
}
}
#paragon-portal-root {
.pgn__modal-layer {
.pgn__modal-close-container {
right: 1rem !important;
}
}
.confirm-modal .pgn__modal-body {
overflow: hidden;
}
}

132
src/App.test.jsx Normal file
View File

@@ -0,0 +1,132 @@
import { render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { App } from './App';
import messages from './messages';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('data/context', () => ({
useMasquerade: jest.fn(() => ({ masqueradeUser: null })),
}));
jest.mock('@edx/frontend-component-footer', () => ({
FooterSlot: jest.fn(() => <div>FooterSlot</div>),
}));
jest.mock('containers/Dashboard', () => jest.fn(() => <div>Dashboard</div>));
jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => <div>LearnerDashboardHeader</div>));
jest.mock('containers/AppWrapper', () => jest.fn(({ children }) => <div className="AppWrapper">{children}</div>));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({})),
}));
jest.mock('@edx/frontend-platform/react', () => ({
...jest.requireActual('@edx/frontend-platform/react'),
ErrorPage: () => 'ErrorPage',
}));
const supportEmail = 'test@support.com';
useInitializeLearnerHome.mockReturnValue({
data: {
platformSettings: {
supportEmail,
},
},
isError: false,
});
describe('App router component', () => {
describe('component', () => {
const runBasicTests = () => {
it('displays title in helmet component', async () => {
await waitFor(() => expect(document.title).toEqual(messages.pageTitle.defaultMessage));
});
it('displays learner dashboard header', () => {
const learnerDashboardHeader = screen.getByText('LearnerDashboardHeader');
expect(learnerDashboardHeader).toBeInTheDocument();
});
it('wraps the header and main components in an AppWrapper widget container', () => {
const appWrapper = screen.getByText('LearnerDashboardHeader').parentElement;
expect(appWrapper).toHaveClass('AppWrapper');
expect(appWrapper.children[1].id).toEqual('main');
});
it('displays footer slot', () => {
const footerSlot = screen.getByText('FooterSlot');
expect(footerSlot).toBeInTheDocument();
});
};
describe('no network failure', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
});
});
describe('no network failure with optimizely url', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
});
});
describe('no network failure with optimizely project id', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
});
});
describe('initialize failure', () => {
beforeEach(() => {
jest.clearAllMocks();
useInitializeLearnerHome.mockReturnValue({
data: null,
isError: true,
});
getConfig.mockReturnValue({});
render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>);
});
runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
const errorPage = screen.getByText('ErrorPage');
expect(errorPage).toBeInTheDocument();
});
});
describe('refresh failure', () => {
beforeEach(() => {
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
const errorPage = screen.getByText('ErrorPage');
expect(errorPage).toBeInTheDocument();
});
});
});
});

View File

@@ -1,20 +0,0 @@
import { Provider as ReduxProvider } from 'react-redux';
import { CurrentAppProvider, PageWrap } from '@openedx/frontend-base';
import { appId } from './constants';
import store from './data/store';
import Dashboard from './containers/Dashboard';
import './app.scss';
const Main = () => (
<CurrentAppProvider appId={appId}>
<ReduxProvider store={store}>
<PageWrap>
<Dashboard />
</PageWrap>
</ReduxProvider>
</CurrentAppProvider>
);
export default Main;

View File

@@ -1 +0,0 @@
module.exports = 'FileMock';

View File

@@ -1 +0,0 @@
module.exports = 'SvgURL';

View File

@@ -1,38 +0,0 @@
@use "@openedx/frontend-base/shell/app.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work
#learnerDashboardRoot {
main {
flex-grow: 1;
}
// Removing a odd 1.5 scaling on checkboxes.:
input[type=checkbox] {
transform: none;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert.alert-info .alert-icon {
color: black;
}
#paragon-portal-root {
.pgn__modal-layer {
.pgn__modal-close-container {
right: 1rem !important;
}
}
.confirm-modal .pgn__modal-body {
overflow: hidden;
}
}
}

View File

@@ -1,23 +0,0 @@
import { App } from '@openedx/frontend-base';
import { appId } from './constants';
import routes from './routes';
import providers from './providers';
import messages from './i18n';
import slots from './slots';
const app: App = {
appId,
routes,
providers,
messages,
slots,
config: {
LEARNING_BASE_URL: 'http://apps.local.openedx.io:2000',
ENABLE_PROGRAMS: false,
ECOMMERCE_BASE_URL: '',
ORDER_HISTORY_URL: '',
SUPPORT_URL: '',
}
};
export default app;

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

15
src/assets/top_stripe.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg width="1350" height="7" viewBox="0 0 1350 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3302_26079)">
<rect width="1350" height="6.75" transform="translate(0 -0.375)" fill="#03C7E8"/>
<rect y="-0.375" width="585.562" height="6.75" fill="#D23228"/>
<path d="M549.281 -0.375H933.188L929.491 6.375H549.281V-0.375Z" fill="#002121"/>
<path d="M550.129 13.125L545.062 -10.5L555.188 -10.5L550.129 13.125Z" fill="#D23228"/>
<path d="M931.082 13.125L925.594 -6.28125L936.563 -6.28125L931.082 13.125Z" fill="#002121"/>
<path d="M0 -0.375H106.312L105.289 6.375H0V-0.375Z" fill="#921108"/>
</g>
<defs>
<clipPath id="clip0_3302_26079">
<rect width="1350" height="6.75" fill="white" transform="translate(0 -0.375)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 765 B

29
src/config/index.js Normal file
View File

@@ -0,0 +1,29 @@
const configuration = {
// BASE_URL: process.env.BASE_URL,
APP_ID: process.env.APP_ID,
LMS_BASE_URL: process.env.LMS_BASE_URL,
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL,
CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL,
// LOGIN_URL: process.env.LOGIN_URL,
// LOGOUT_URL: process.env.LOGOUT_URL,
// CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
// REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
// DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
// SECURE_COOKIES: process.env.NODE_ENV !== 'development',
SEGMENT_KEY: process.env.SEGMENT_KEY,
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
SUPPORT_URL: process.env.SUPPORT_URL || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
LOGO_URL: process.env.LOGO_URL,
ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true',
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
SHOW_UNENROLL_SURVEY: process.env.SHOW_UNENROLL_SURVEY === 'true',
};
const features = {};
export { configuration, features };

View File

@@ -1 +0,0 @@
export const appId = 'org.openedx.frontend.app.learnerDashboard';

View File

@@ -0,0 +1,13 @@
import PropTypes from 'prop-types';
export const AppWrapper = ({
children,
}) => children;
AppWrapper.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]).isRequired,
};
export default AppWrapper;

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,22 +1,29 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@openedx/frontend-base';
import track from '../../../../tracking';
import { reduxHooks } from '../../../../hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import track from 'tracking';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const BeginCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { data: learnerData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const homeUrl = courseData?.courseRun?.homeUrl;
const execEdTrackingParam = useMemo(() => {
const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode);
const { authOrgId } = learnerData.enterpriseDashboard || {};
return isExecEd2UCourse ? `?org_id=${authOrgId}` : '';
}, [courseData.enrollment.mode, learnerData.enterpriseDashboard]);
const { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
const handleClick = useCourseTrackingEvent(
track.course.enterCourseClicked,
cardId,
homeUrl + execEdTrackingParam,

View File

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

View File

@@ -1,22 +1,29 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@openedx/frontend-base';
import track from '../../../../tracking';
import { reduxHooks } from '../../../../hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import track from 'tracking';
import { useCourseTrackingEvent, useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const ResumeButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { data: learnerData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const resumeUrl = courseData?.courseRun?.resumeUrl;
const execEdTrackingParam = useMemo(() => {
const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode);
const { authOrgId } = learnerData.enterpriseDashboard || {};
return isExecEd2UCourse ? `?org_id=${authOrgId}` : '';
}, [courseData.enrollment.mode, learnerData.enterpriseDashboard]);
const { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
const handleClick = useCourseTrackingEvent(
track.course.enterCourseClicked,
cardId,
resumeUrl + execEdTrackingParam,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
beginCourse: {

View File

@@ -1,28 +1,47 @@
/* eslint-disable max-len */
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { MailtoLink, Hyperlink } from '@openedx/paragon';
import { CheckCircle } from '@openedx/paragon/icons';
import { useIntl } from '@openedx/frontend-base';
import { useIntl } from '@edx/frontend-platform/i18n';
import { baseAppUrl } from 'data/services/lms/urls';
import { utilHooks, reduxHooks } from '../../../../hooks';
import Banner from '../../../../components/Banner';
import { useInitializeLearnerHome } from 'data/hooks';
import { utilHooks, useCourseData } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
const { useFormatDate } = utilHooks;
export const CertificateBanner = ({ cardId }) => {
const certificate = reduxHooks.useCardCertificateData(cardId);
const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const {
isAudit,
isVerified,
} = reduxHooks.useCardEnrollmentData(cardId);
const { isPassing } = reduxHooks.useCardGradeData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId);
const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData();
certificate = {},
isVerified = false,
isAudit = false,
isPassing = false,
isArchived = false,
minPassingGrade = 0,
progressUrl = '',
} = useMemo(() => ({
isVerified: courseData?.enrollment?.isVerified,
isAudit: courseData?.enrollment?.isAudit,
certificate: courseData?.certificate || {},
isPassing: courseData?.gradeData?.isPassing,
isArchived: courseData?.courseRun?.isArchived,
minPassingGrade: Math.floor((courseData?.courseRun?.minPassingGrade ?? 0) * 100),
progressUrl: baseAppUrl(courseData?.courseRun?.progressUrl || ''),
}), [courseData]);
const { supportEmail, billingEmail } = useMemo(
() => ({
supportEmail: learnerHomeData?.platformSettings?.supportEmail,
billingEmail: learnerHomeData?.platformSettings?.billingEmail,
}),
[learnerHomeData],
);
const { formatMessage } = useIntl();
const formatDate = useFormatDate();
@@ -31,7 +50,7 @@ export const CertificateBanner = ({ cardId }) => {
if (certificate.isRestricted) {
return (
<Banner variant="danger">
{supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)}
{ supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)}
{isVerified && ' '}
{isVerified && (billingEmail ? formatMessage(messages.certRefundContactBilling, { billingEmail: emailLink(billingEmail) }) : formatMessage(messages.certRefundContactBillingNoEmail))}
</Banner>
@@ -75,7 +94,7 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (certificate.isEarnedButUnavailable) {
if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) {
return (
<Banner>
{formatMessage(

View File

@@ -1,20 +1,20 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from '@src/hooks';
import { useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import CertificateBanner from './CertificateBanner';
jest.mock('@src/hooks', () => ({
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: jest.fn(() => date => date),
},
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardGradeData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
useCourseData: jest.fn(),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
const defaultCertificate = {
@@ -35,9 +35,14 @@ const supportEmail = 'suport@email.com';
const billingEmail = 'billing@email.com';
describe('CertificateBanner', () => {
reduxHooks.useCardCourseRunData.mockReturnValue({
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
useCourseData.mockReturnValue({
enrollment: {},
certificate: {},
gradeData: {},
courseRun: {
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
},
});
const createWrapper = ({
certificate = {},
@@ -46,11 +51,17 @@ describe('CertificateBanner', () => {
courseRun = {},
platformSettings = {},
}) => {
reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade });
reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun });
reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings });
useCourseData.mockReturnValue({
enrollment: { ...defaultEnrollment, ...enrollment },
certificate: { ...defaultCertificate, ...certificate },
gradeData: { ...defaultGrade, ...grade },
courseRun: {
...defaultCourseRun,
...courseRun,
},
});
const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } };
useInitializeLearnerHome.mockReturnValue(lernearData);
return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>);
};
beforeEach(() => {
@@ -222,7 +233,8 @@ describe('CertificateBanner', () => {
isPassing: true,
},
certificate: {
isEarnedButUnavailable: true,
isEarned: true,
availableDate: '10/20/3030',
},
});
const banner = screen.getByRole('alert');
@@ -239,4 +251,27 @@ describe('CertificateBanner', () => {
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
});
it('should use default values when courseData is empty or undefined', () => {
useCourseData.mockReturnValue({});
const lernearData = { data: { platformSettings: { supportEmail } } };
useInitializeLearnerHome.mockReturnValue(lernearData);
render(<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,22 +1,26 @@
/* eslint-disable max-len */
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon';
import { useIntl } from '@openedx/frontend-base';
import { utilHooks, reduxHooks } from '../../../../hooks';
import Banner from '../../../../components/Banner';
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, useCourseData } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
export const CourseBanner = ({ cardId }) => {
const {
isVerified,
isAuditAccessExpired,
coursewareAccess = {},
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const {
isVerified = false,
isAuditAccessExpired = false,
coursewareAccess = {},
} = useMemo(() => ({
isVerified: courseData.enrollment?.isVerified,
isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired,
coursewareAccess: courseData.enrollment?.coursewareAccess || {},
}), [courseData]);
const courseRun = courseData?.courseRun || {};
const formatDate = utilHooks.useFormatDate();
const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess;

View File

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

View File

@@ -1,5 +1,8 @@
import { StrictDict } from '../../../../../utils';
import { reduxHooks } from '../../../../../hooks';
import { useMemo } from 'react';
import { useInitializeLearnerHome } from 'data/hooks';
import { StrictDict } from 'utils';
import { useCourseData } from 'hooks';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
@@ -14,11 +17,29 @@ export const statusComponents = StrictDict({
});
export const useCreditBannerData = (cardId) => {
const credit = reduxHooks.useCardCreditData(cardId);
const { supportEmail } = reduxHooks.usePlatformSettingsData();
if (!credit.isEligible) {
return null;
}
const courseData = useCourseData(cardId);
const { data: learnerHomeData } = useInitializeLearnerHome();
const supportEmail = useMemo(
() => (learnerHomeData?.platformSettings?.supportEmail),
[learnerHomeData],
);
const credit = useMemo(() => {
const creditData = courseData?.credit;
if (!creditData || Object.keys(creditData).length === 0) {
return { isEligible: false };
}
return {
isEligible: true,
providerStatusUrl: creditData.providerStatusUrl,
providerName: creditData.providerName,
providerId: creditData.providerId,
error: creditData.error,
purchased: creditData.purchased,
requestStatus: creditData.requestStatus,
};
}, [courseData]);
if (!credit.isEligible || !courseData?.credit?.isEligible) { return null; }
const { error, purchased, requestStatus } = credit;
let ContentComponent = EligibleContent;

View File

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

View File

@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@openedx/frontend-base';
import { useIntl } from '@edx/frontend-platform/i18n';
import Banner from '../../../../../components/Banner';
import Banner from 'components/Banner';
import { MailtoLink } from '@openedx/paragon';
import hooks from './hooks';

View File

@@ -1,5 +1,5 @@
import { screen, render } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import hooks from './hooks';
import { CreditBanner } from '.';

View File

@@ -1,14 +1,14 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error: {
id: 'learner-dash.courseCard.banners.credit.error',
description: 'Error message for credit transaction with support email link',
description: '',
defaultMessage: 'An error occurred with this transaction. For help, contact {supportEmailLink}.',
},
errorNoEmail: {
id: 'learner-dash.courseCard.banners.credit.errorNoEmail',
description: 'Error message for credit transaction without support email',
description: '',
defaultMessage: 'An error occurred with this transaction.',
},
});

View File

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

View File

@@ -1,15 +1,13 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { formatMessage } from '@src/testUtils';
import { reduxHooks } from '@src/hooks';
import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { useCourseData, useIsMasquerading } from 'hooks';
import messages from './messages';
import ApprovedContent from './ApprovedContent';
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
}));
const cardId = 'test-card-id';
@@ -17,28 +15,21 @@ const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
const renderWithMasquerading = (isMasquerading = false) => render(
<IntlProvider locale="en">
<MasqueradeUserContext.Provider value={{ isMasquerading }}>
<ApprovedContent cardId={cardId} />
</MasqueradeUserContext.Provider>
</IntlProvider>
);
useCourseData.mockReturnValue({ credit });
useIsMasquerading.mockReturnValue(false);
describe('ApprovedContent component', () => {
describe('hooks', () => {
it('initializes credit data with cardId', () => {
renderWithMasquerading();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
expect(useCourseData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
jest.clearAllMocks();
renderWithMasquerading();
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
});
it('action.message is formatted viewCredit message', () => {
const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage });
@@ -63,7 +54,8 @@ describe('ApprovedContent component', () => {
});
describe('when masquerading', () => {
beforeEach(() => {
renderWithMasquerading(true);
useIsMasquerading.mockReturnValue(true);
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
});
it('disables the action button', () => {

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useContext } from 'react';
import { useIntl } from '@openedx/frontend-base';
import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsMasquerading } from 'hooks';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import hooks from './hooks';
@@ -12,7 +13,7 @@ import messages from './messages';
export const MustRequestContent = ({ cardId }) => {
const { formatMessage } = useIntl();
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
const { isMasquerading } = useContext(MasqueradeUserContext);
const isMasquerading = useIsMasquerading();
return (
<CreditContent
action={{

View File

@@ -1,9 +1,7 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import { reduxHooks } from '@src/hooks';
import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext';
import { useCourseData, useIsMasquerading } from 'hooks';
import messages from './messages';
import hooks from './hooks';
import MustRequestContent from './MustRequestContent';
@@ -12,10 +10,9 @@ jest.mock('./hooks', () => ({
useCreditRequestData: jest.fn(),
}));
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
}));
const cardId = 'test-card-id';
@@ -31,11 +28,9 @@ const providerName = 'test-credit-provider-name';
const providerStatusUrl = 'test-credit-provider-status-url';
const createCreditRequest = jest.fn().mockName('createCreditRequest');
const renderMustRequestContent = (isMasquerading = false) => render(
const renderMustRequestContent = () => render(
<IntlProvider locale="en" messages={messages}>
<MasqueradeUserContext.Provider value={{ isMasquerading }}>
<MustRequestContent cardId={cardId} />
</MasqueradeUserContext.Provider>
<MustRequestContent cardId={cardId} />
</IntlProvider>,
);
@@ -46,9 +41,12 @@ describe('MustRequestContent component', () => {
requestData,
createCreditRequest,
});
reduxHooks.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
useIsMasquerading.mockReturnValue(false);
useCourseData.mockReturnValue({
credit: {
providerName,
providerStatusUrl,
},
});
});
@@ -91,13 +89,14 @@ describe('MustRequestContent component', () => {
describe('when masquerading', () => {
beforeEach(() => {
renderMustRequestContent(true);
useIsMasquerading.mockReturnValue(true);
renderMustRequestContent();
});
it('disables the request credit button', () => {
const button = screen.getByRole('button', { name: /request credit/i });
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
});
});

View File

@@ -1,15 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useContext } from 'react';
import { useIntl } from '@openedx/frontend-base';
import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext';
import { reduxHooks } from '../../../../../../hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import CreditContent from './components/CreditContent';
import messages from './messages';
export const PendingContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
const { isMasquerading } = useContext(MasqueradeUserContext);
const courseData = useCourseData(cardId);
const { providerStatusUrl: href, providerName } = courseData?.credit || {};
const isMasquerading = useIsMasquerading();
const { formatMessage } = useIntl();
return (
<CreditContent

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
import { keyStore } from '@src/utils';
import { keyStore } from 'utils';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';

View File

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

View File

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

View File

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

View File

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

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,59 +1,59 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
approved: {
id: 'learner-dash.courseCard.banners.credit.approved',
description: 'Message shown when credit request has been approved',
description: '',
defaultMessage: '{congratulations} {providerName} has approved your request for course credit. To see your course credit, visit the {linkToProviderSite} website.',
},
congratulations: {
id: 'learner-dash.courseCard.banners.credit.congratulations',
description: 'Congratulatory message for credit approval',
description: '',
defaultMessage: 'Congratulations!',
},
eligible: {
id: 'learner-dash.courseCard.banners.credit.eligible',
description: 'Message shown when user is eligible to purchase course credit',
description: '',
defaultMessage: 'You have completed this course and are eligible to purchase course credit. Select {getCredit} to get started.',
},
eligibleFromProvider: {
id: 'learner-dash.courseCard.banners.credit.eligibleFromProvider',
description: 'Message shown when user is eligible for credit from a specific provider',
description: '',
defaultMessage: 'You are now eligible for credit from {providerName}. Congratulations!',
},
getCredit: {
id: 'learner-dash.courseCard.banners.credit.getCredit',
description: 'Button text for initiating the credit process',
description: '',
defaultMessage: 'Get Credit',
},
mustRequest: {
id: 'learner-dash.courseCard.banners.credit.mustRequest',
description: 'Message shown after payment to instruct user to request credit',
description: '',
defaultMessage: 'Thank you for your payment. To receive course credit, you must request credit at the {linkToProviderSite} website. Select {requestCredit} to get started',
},
received: {
id: 'learner-dash.courseCard.banners.credit.received',
description: 'Message shown when credit request has been received',
description: '',
defaultMessage: '{providerName} has received your course credit request. We will update you when credit processing is complete.',
},
rejected: {
id: 'learner-dash.courseCard.banners.credit.rejected',
description: 'Message shown when credit request has been rejected',
description: '',
defaultMessage: '{providerName} did not approve your request for course credit. For more information, contact {linkToProviderSite} directly.',
},
requestCredit: {
id: 'learner-dash.courseCard.banners.credit.requestCredit',
description: 'Button text for requesting credit',
description: '',
defaultMessage: 'Request Credit',
},
viewCredit: {
id: 'learner-dash.courseCard.banners.credit.viewCredit',
description: 'Button text for viewing credit details',
description: '',
defaultMessage: 'View Credit',
},
viewDetails: {
id: 'learner-dash.courseCard.banners.credit.viewDetails',
description: 'Button text for viewing credit request details',
description: '',
defaultMessage: 'View Details',
},
});

View File

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

View File

@@ -1,22 +1,40 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { formatMessage } from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { reduxHooks } from '@src/hooks';
import { useCourseData } from 'hooks';
import EntitlementBanner from './EntitlementBanner';
import messages from './messages';
jest.mock('@src/hooks', () => ({
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: (fn) => fn(),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({
data: {
platformSettings: {
supportEmail: 'test-support-email',
},
},
}),
}));
const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal');
jest.mock('data/context/SelectSessionProvider', () => ({
useSelectSessionModal: () => ({
updateSelectSessionModal: mockUpdateSelectSessionModal,
}),
}));
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
utilHooks: {
useFormatDate: () => date => date,
},
reduxHooks: {
usePlatformSettingsData: jest.fn(),
useCardEntitlementData: jest.fn(),
useUpdateSelectSessionModalCallback: jest.fn(
(cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`),
),
useFormatDate: () => date => date?.toDateString(),
},
}));
const cardId = 'test-card-id';
@@ -32,16 +50,20 @@ const platformData = { supportEmail: 'test-support-email' };
const renderComponent = (overrides = {}) => {
const { entitlement = {} } = overrides;
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement });
reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData);
useCourseData.mockReturnValue({
entitlement: { ...entitlementData, ...entitlement },
platformSettings: platformData,
});
return render(<IntlProvider locale="en"><EntitlementBanner cardId={cardId} /></IntlProvider>);
};
describe('EntitlementBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes data with course number from entitlement', () => {
renderComponent();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId);
expect(useCourseData).toHaveBeenCalledWith(cardId);
});
it('no display if not an entitlement', () => {
renderComponent({ entitlement: { isEntitlement: false } });
@@ -56,7 +78,10 @@ describe('EntitlementBanner', () => {
expect(banner.innerHTML).toContain(platformData.supportEmail);
});
it('renders when expiration warning', () => {
renderComponent({ entitlement: { showExpirationWarning: true } });
const deadline = new Date();
deadline.setDate(deadline.getDate() + 4);
const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`;
renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-info');
@@ -64,9 +89,37 @@ describe('EntitlementBanner', () => {
expect(button).toBeInTheDocument();
});
it('renders expired banner', () => {
renderComponent({ entitlement: { isExpired: true } });
renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired));
});
it('should call updateSelectSessionModal with cardId when select session button is clicked', async () => {
const user = userEvent.setup();
const deadline = new Date();
deadline.setDate(deadline.getDate() + 4);
const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`;
renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-info');
const button = screen.getByRole('button', { name: formatMessage(messages.selectSession) });
expect(button).toBeInTheDocument();
await user.click(button);
expect(mockUpdateSelectSessionModal).toHaveBeenCalledWith(cardId);
});
it('should return null when isExpired is false and showExpirationWarning is false', () => {
renderComponent({
entitlement: {
isEntitlement: true,
hasSessions: true,
isFulfilled: true,
showExpirationWarning: false,
isExpired: false,
},
});
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
});
});

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
relatedPrograms: {

View File

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

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';
import { MemoryRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from '@src/hooks';
import CourseCardBanners from '.';
jest.mock('./CourseBanner', () => jest.fn(() => <div>CourseBanner</div>));
@@ -19,10 +19,12 @@ const mockedComponents = [
'RelatedProgramsBanner',
];
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(() => ({
enrollment: {
isEnrolled: true,
},
})),
}));
describe('CourseCardBanners', () => {
@@ -30,28 +32,20 @@ describe('CourseCardBanners', () => {
cardId: 'test-card-id',
};
it('renders default CourseCardBanners', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: true });
render(
<MemoryRouter>
<IntlProvider locale="en">
<CourseCardBanners {...props} />
</IntlProvider>
</MemoryRouter>
);
render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
mockedComponents.map((componentName) => {
const mockedComponent = screen.getByText(componentName);
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', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false });
render(
<MemoryRouter>
<IntlProvider locale="en">
<CourseCardBanners {...props} />
</IntlProvider>
</MemoryRouter>
);
useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } });
render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2);
mockedComponentsIfNotEnrolled.map((componentName) => {
const mockedComponent = screen.getByText(componentName);

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
auditAccessExpired: {

View File

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

View File

@@ -1,28 +1,32 @@
import { useIntl } from '@openedx/frontend-base';
import { useIntl } from '@edx/frontend-platform/i18n';
import { keyStore } from '@src/utils';
import { utilHooks, reduxHooks } from '@src/hooks';
import { keyStore } from 'utils';
import { utilHooks, useCourseData } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import * as hooks from './hooks';
import messages from './messages';
jest.mock('@src/hooks', () => ({
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: (fn) => fn(),
}));
const updateSelectSessionModalMock = jest.fn().mockName('updateSelectSessionModal');
jest.mock('data/context', () => ({
useSelectSessionModal: jest.fn(),
}));
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
utilHooks: {
useFormatDate: jest.fn(),
},
reduxHooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useCardProviderData: jest.fn(),
useUpdateSelectSessionModalCallback: (...args) => ({ updateSelectSessionModalCallback: args }),
},
}));
jest.mock('@openedx/frontend-base', () => {
const { formatMessage } = jest.requireActual('@src/testUtils');
jest.mock('@edx/frontend-platform/i18n', () => {
const { formatMessage } = jest.requireActual('testUtils');
return {
...jest.requireActual('@openedx/frontend-base'),
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage,
}),
@@ -45,8 +49,9 @@ describe('CourseCardDetails hooks', () => {
});
describe('useCardDetailsData', () => {
const providerName = 'my-provider-name';
const providerData = {};
const providerData = {
name: 'my-provider-name',
};
const entitlementData = {
isEntitlement: false,
disableViewCourse: false,
@@ -58,15 +63,13 @@ describe('CourseCardDetails hooks', () => {
const runHook = ({ provider = {}, entitlement = {} }) => {
jest.spyOn(hooks, hookKeys.useAccessMessage)
.mockImplementationOnce(mockAccessMessage);
reduxHooks.useCardProviderData.mockReturnValueOnce({
...providerData,
...provider,
useCourseData.mockReturnValue({
courseProvider: { ...providerData, ...provider },
course: { courseNumber },
courseRun: {},
entitlement: { ...entitlementData, ...entitlement },
});
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
...entitlementData,
...entitlement,
});
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber });
useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock });
out = hooks.useCardDetailsData({ cardId });
};
beforeEach(() => {
@@ -76,15 +79,17 @@ describe('CourseCardDetails hooks', () => {
expect(out.accessMessage).toEqual(mockAccessMessage({ cardId }));
});
it('forwards provider name if it exists, else formatted unknown provider name', () => {
runHook({ provider: { name: providerName } });
expect(out.providerName).toEqual(providerName);
runHook({ provider: {} });
expect(out.providerName).toEqual(providerData.name);
runHook({ provider: { name: '' } });
expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName));
});
it('forward changeOrLeaveSessionMessage', () => {
expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton));
});
it('calls updateSelectSessionModal when openSessionModal is called', () => {
out.openSessionModal();
expect(updateSelectSessionModalMock).toHaveBeenCalledWith(cardId);
});
});
describe('useAccessMessage', () => {
@@ -101,21 +106,16 @@ describe('CourseCardDetails hooks', () => {
endDate: '10/20/2000',
};
const runHook = ({ enrollment = {}, courseRun = {} }) => {
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
useCourseData.mockReturnValue({
courseRun: { ...courseRunData, ...courseRun },
enrollment: { ...enrollmentData, ...enrollment },
});
out = hooks.useAccessMessage({ cardId });
};
it('loads data from enrollment and course run data based on course number', () => {
runHook({});
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(useCourseData).toHaveBeenCalledWith(cardId);
});
describe('if not started yet', () => {

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
accessExpired: {

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,27 @@
import { IntlProvider } from '@openedx/frontend-base';
import { render, screen } from '@testing-library/react';
import { when } from 'jest-when';
import track from '@src/tracking';
import { reduxHooks } from '@src/hooks';
import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext';
import { useEmailSettings } from './hooks';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import track from 'tracking';
import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
import { useEmailSettings, useCardSocialSettingsData } from './hooks';
import SocialShareMenu from './SocialShareMenu';
import messages from './messages';
jest.mock('@src/tracking', () => ({
jest.mock('tracking', () => ({
socialShare: 'test-social-share-key',
}));
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
useIsMasquerading: jest.fn(),
}));
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
useCardSocialSettingsData: jest.fn(),
}));
const props = {
@@ -55,31 +54,28 @@ const socialShare = {
const mockHooks = (returnVals = {}) => {
mockHook(
reduxHooks.useCardEnrollmentData,
useCourseData,
{
isEmailEnabled: !!returnVals.isEmailEnabled,
isExecEd2UCourse: !!returnVals.isExecEd2UCourse,
enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled,
mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard',
},
course: { courseName },
},
{ isCardHook: true },
);
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
mockHook(
reduxHooks.useCardSocialSettingsData,
useCardSocialSettingsData,
{
facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled },
},
{ isCardHook: true },
);
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
};
const renderComponent = (isMasquerading = false) => render(
<IntlProvider locale="en">
<MasqueradeUserContext.Provider value={{ isMasquerading }}>
<SocialShareMenu {...props} />
</MasqueradeUserContext.Provider>
</IntlProvider>,
);
const renderComponent = () => render(<IntlProvider locale="en"><SocialShareMenu {...props} /></IntlProvider>);
describe('SocialShareMenu', () => {
describe('behavior', () => {
@@ -90,12 +86,12 @@ describe('SocialShareMenu', () => {
it('initializes local hooks', () => {
when(useEmailSettings).expectCalledWith();
});
it('initializes redux hook data ', () => {
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId);
when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId);
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
it('initializes hook data ', () => {
when(useCourseData).expectCalledWith(props.cardId);
when(useCardSocialSettingsData).expectCalledWith(props.cardId);
when(useIsMasquerading).expectCalledWith();
when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
});
});
describe('render', () => {
@@ -118,9 +114,7 @@ describe('SocialShareMenu', () => {
if (isMasquerading) {
it('is disabled', () => {
const emailSettingsButton = screen.getByRole('button', { name: messages.emailSettings.defaultMessage });
expect(emailSettingsButton).toBeInTheDocument();
expect(emailSettingsButton).toHaveAttribute('aria-disabled', 'true');
expect(emailSettingsButton).toHaveClass('disabled');
});
} else {
it('is enabled', () => {
@@ -171,8 +165,8 @@ describe('SocialShareMenu', () => {
});
describe('masquerading', () => {
beforeEach(() => {
mockHooks({ isEmailEnabled: true });
renderComponent(true);
mockHooks({ isEmailEnabled: true, isMasquerading: true });
renderComponent();
});
testEmailSettingsDropdown(true);
});

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
import { useContext } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@openedx/frontend-base';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon, IconButton } from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import MasqueradeUserContext from '../../../../data/contexts/MasqueradeUserContext';
import EmailSettingsModal from '../../../../containers/EmailSettingsModal';
import UnenrollConfirmModal from '../../../../containers/UnenrollConfirmModal';
import { reduxHooks } from '../../../../hooks';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { useCourseData, useIsMasquerading } from 'hooks';
import SocialShareMenu from './SocialShareMenu';
import {
useEmailSettings,
@@ -26,13 +23,15 @@ export const testIds = {
export const CourseCardMenu = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
const emailSettings = useEmailSettings();
const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId);
const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId);
const { isMasquerading } = useContext(MasqueradeUserContext);
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const isMasquerading = useIsMasquerading();
if (!shouldShowDropdown) {
return null;

View File

@@ -1,21 +1,21 @@
import { when } from 'jest-when';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@openedx/frontend-base';
import { reduxHooks } from '@src/hooks';
import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import * as hooks from './hooks';
import CourseCardMenu from '.';
import messages from './messages';
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardEnrollmentData: jest.fn(),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
}));
jest.mock('./SocialShareMenu', () => jest.fn(() => <div>SocialShareMenu</div>));
jest.mock('@src/containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>));
jest.mock('@src/containers/UnenrollConfirmModal', () => jest.fn(() => <div>UnenrollConfirmModal</div>));
jest.mock('containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>));
jest.mock('containers/UnenrollConfirmModal', () => jest.fn(() => <div>UnenrollConfirmModal</div>));
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
useUnenrollData: jest.fn(),
@@ -67,20 +67,19 @@ const mockHooks = (returnVals = {}) => {
},
{ isCardHook: true },
);
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
mockHook(
reduxHooks.useCardEnrollmentData,
{ isEmailEnabled: !!returnVals.isEmailEnabled },
useCourseData,
{
enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled,
},
},
{ isCardHook: true },
);
};
const renderComponent = (isMasquerading = false) => render(
<IntlProvider locale="en">
<MasqueradeUserContext.Provider value={{ isMasquerading }}>
<CourseCardMenu {...props} />
</MasqueradeUserContext.Provider>
</IntlProvider>
);
const renderComponent = () => render(<IntlProvider locale="en"><CourseCardMenu {...props} /></IntlProvider>);
describe('CourseCardMenu', () => {
describe('hooks', () => {
@@ -90,12 +89,10 @@ describe('CourseCardMenu', () => {
});
it('initializes local hooks', () => {
when(hooks.useEmailSettings).expectCalledWith();
when(hooks.useUnenrollData).expectCalledWith();
when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId);
when(hooks.useOptionVisibility).expectCalledWith(props.cardId);
});
it('initializes redux hook data ', () => {
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
it('initializes hook data ', () => {
when(useIsMasquerading).expectCalledWith();
when(useCourseData).expectCalledWith(props.cardId);
});
});
describe('render', () => {
@@ -155,13 +152,14 @@ describe('CourseCardMenu', () => {
});
describe('masquerading', () => {
it('renders but unenroll is disabled', async () => {
mockHooks({ ...hookProps });
renderComponent(true);
mockHooks({ ...hookProps, isMasquerading: true });
renderComponent();
const user = userEvent.setup();
const dropdown = screen.getByRole('button', { name: messages.dropdownAlt.defaultMessage });
expect(dropdown).toBeInTheDocument();
await user.click(dropdown);
const unenrollOption = screen.getByRole('button', { name: messages.unenroll.defaultMessage });
expect(unenrollOption).toBeInTheDocument();
expect(unenrollOption).toHaveAttribute('aria-disabled', 'true');

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
unenroll: {

View File

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

View File

@@ -1,22 +1,19 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { reduxHooks } from '@src/hooks';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import track from 'tracking';
import useActionDisabledState from './hooks';
import CourseCardTitle from './CourseCardTitle';
import track from '@src/tracking';
jest.mock('@src/tracking', () => ({
jest.mock('tracking', () => ({
course: {
courseTitleClicked: jest.fn().mockName('segment.courseTitleClicked'),
},
}));
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useCourseTrackingEvent: jest.fn(),
}));
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
@@ -32,9 +29,11 @@ describe('CourseCardTitle', () => {
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick);
useCourseData.mockReturnValue({
course: { courseName },
courseRun: { homeUrl },
});
useCourseTrackingEvent.mockReturnValue(handleTitleClick);
});
it('renders course name as link when not disabled', async () => {
@@ -62,9 +61,8 @@ describe('CourseCardTitle', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
render(<CourseCardTitle {...props} />);
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.courseTitleClicked,
props.cardId,
homeUrl,

View File

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

View File

@@ -1,20 +1,19 @@
import { useIntl } from '@openedx/frontend-base';
import { useIntl } from '@edx/frontend-platform/i18n';
import { MockUseState } from '@src/testUtils';
import { reduxHooks } from '@src/hooks';
import { MockUseState } from 'testUtils';
import { useCourseData } from 'hooks';
import * as hooks from './hooks';
import messages from './messages';
jest.mock('@src/hooks', () => ({
reduxHooks: {
useCardRelatedProgramsData: jest.fn(),
},
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
}));
jest.mock('@openedx/frontend-base', () => {
const { formatMessage } = jest.requireActual('@src/testUtils');
jest.mock('@edx/frontend-platform/i18n', () => {
const { formatMessage } = jest.requireActual('testUtils');
return {
...jest.requireActual('@openedx/frontend-base'),
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage,
}),
@@ -24,7 +23,7 @@ jest.mock('@openedx/frontend-base', () => {
const cardId = 'test-card-id';
const state = new MockUseState(hooks);
let numPrograms = 27;
const numPrograms = 27;
describe('RelatedProgramsBadge hooks', () => {
const { formatMessage } = useIntl();
@@ -34,15 +33,14 @@ describe('RelatedProgramsBadge hooks', () => {
});
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({
length: numPrograms,
});
});
describe('useRelatedProgramsBadgeData', () => {
beforeEach(() => {
state.mock();
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({
length: numPrograms,
useCourseData.mockReturnValue({
programs: {
relatedPrograms: new Array(numPrograms).fill({}),
},
});
out = hooks.useRelatedProgramsBadgeData({ cardId });
});
@@ -66,14 +64,12 @@ describe('RelatedProgramsBadge hooks', () => {
expect(out.numPrograms).toEqual(numPrograms);
});
test('returns empty programsMessage if no programs', () => {
reduxHooks.useCardRelatedProgramsData.mockReset();
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 });
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } });
out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual('');
});
test('returns badgeLabelSingular programsMessage if 1 programs', () => {
reduxHooks.useCardRelatedProgramsData.mockReset();
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 });
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } });
out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual(formatMessage(
messages.badgeLabelSingular,

View File

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { Button, Icon } from '@openedx/paragon';
import { Program } from '@openedx/paragon/icons';
import RelatedProgramsBadgeModal from '@src/containers/RelatedProgramsModal';
import RelatedProgramsBadgeModal from 'containers/RelatedProgramsModal';
import useRelatedProgramsBadgeData from './hooks';
export const RelatedProgramsBadge = ({ cardId }) => {

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