Compare commits

...

125 Commits

Author SHA1 Message Date
Jody Bailey
b1e4e2d414 fix: Merge branch 'master' into development branch 2023-06-12 16:43:28 +02:00
Leangseu Kim
a479b7ead6 fix: stop user from unenroll after earned the certificate 2023-06-12 10:07:28 -04:00
Jody Bailey
f4c27f02ba fix: render logic for open courses and test coverage fix 2023-06-06 16:56:13 +02:00
Jody Bailey
55a647bb5b test: additional product card header tests 2023-06-06 13:45:38 +02:00
Jody Bailey
c3b98d954e fix: additional linting + clarity changes 2023-06-06 10:29:15 +02:00
Kris Hatcher
e43a49b431 feat: add career link to user dropdown (#152)
Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com>
2023-06-05 11:50:40 -04:00
Jody Bailey
91a694736a fix: final confirmed product changes 2023-06-05 17:23:08 +02:00
Jody Bailey
7fe3bf7ab8 fix: Merge branch 'master' into jodybaileyy/add-query-logic-to-CPR-container 2023-06-02 13:48:27 +02:00
Jody Bailey
68db9a9829 feat: Initial render logic for cross product recommendations experiment 2023-06-02 13:45:22 +02:00
Mashal Malik
4643e0b130 refactor: update lock version file (#154) 2023-05-31 17:49:23 +05:00
Bilal Qamar
8c29abd0c8 feat: upgraded to node v18, added .nvmrc and updated workflows (#151) 2023-05-24 12:56:20 +05:00
Jason Wesson
d44b123815 fix: parse creditRequest data correctly and add ecommerce URL to envs (#150) 2023-05-19 11:46:02 -07:00
Jason Wesson
8829f756d8 fix: change ecommerce url reference 2023-05-19 15:29:40 +00:00
Jason Wesson
176a803f94 Merge branch 'master' into jwesson/fix-purchase-credit 2023-05-18 11:22:33 -07:00
Jason Wesson
309a07ffa9 fix: add ecommerce url env to env.prod 2023-05-18 18:11:34 +00:00
Jason Wesson
e3784d36f1 refactor: extract data closer to origin of API request 2023-05-18 18:09:16 +00:00
Jason Wesson
5048fffd04 fix: parse creditRequest data correctly and add ecommerce URL to dev and test envs 2023-05-17 20:19:09 +00:00
Jody Bailey
bdf3870808 chore: attempt at fixing query to CPR endpoint 2023-05-16 11:04:25 +02:00
Jody Bailey
69e7c71885 fix: Merge branch 'master' into jodybaileyy/add-query-logic-to-CPR-container 2023-05-16 09:42:38 +02:00
Jody Bailey
cd7650ab42 chore: added logging for debugginh 2023-05-16 09:26:46 +02:00
Jody Bailey
7bd3452dc3 feat: inital query logic to cross product endpoint 2023-05-16 08:51:40 +02:00
Jody Bailey
be1e1bf7d9 fix: core mark-up and styling for cross product recommendations container 2023-05-15 15:25:33 +02:00
Jenkins
5ca3036849 chore(i18n): update translations 2023-05-14 12:46:46 -04:00
Jody Bailey
807d9f70b8 feat: add cross product recommendations widget 2023-05-11 11:48:08 +02:00
Leangseu Kim
e57f44068b fix: missing image 2023-05-10 11:01:58 -04:00
leangseu-edx
a4d10b6c72 chore: refactor disable course action into a single hook (#145) 2023-05-09 14:30:44 -04:00
Omar Al-Ithawi
5769629250 feat: use atlas in make pull_translations (#137)
- Bump frontend-platform to bring intl-imports.js script
 - Move all i18n imports into `src/i18n/index.js` so intl-imports.js can override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL` environment variable is set.

This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-05-09 10:05:48 -04:00
Jenkins
a59ff5e7e8 chore(i18n): update translations 2023-05-07 12:46:45 -04:00
Leangseu Kim
9a9c0583ca chore: update zendesk chat department 2023-05-03 12:47:06 -04:00
Jenkins
2f409e5168 chore(i18n): update translations 2023-04-30 12:46:45 -04:00
Hamzah Ullah
cf35c7d611 fix: account for isLearnerPortalEnabled when determining hasAvailableDashboards (#141) 2023-04-28 11:29:44 -04:00
Hamzah Ullah
4a2eee2a1d chore: update test and snapshot 2023-04-28 11:09:07 -04:00
Adam Stankiewicz
0ed2b10b13 fix: change button case for enterpriseDialogConfirmButton 2023-04-26 16:57:41 -04:00
Adam Stankiewicz
01f67265f6 chore: remove console.log 2023-04-26 16:40:20 -04:00
Adam Stankiewicz
8a73043368 fix: account for isLearnerPortalEnabled when determining hasAvailableDashboards 2023-04-26 16:00:38 -04:00
Jansen Kantor
b09c36e13e fix: noticeswrapper api response error (#139) 2023-04-21 10:09:22 -04:00
Jansen Kantor
14f7389900 Jkantor/notices (#134)
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2023-04-19 14:35:46 -04:00
Leangseu Kim
895e867b91 fix: make credit actions disable on masquerade 2023-04-19 11:55:05 -04:00
Leangseu Kim
6bc60bad33 chore: remove incorrect information in unenroll popup 2023-04-19 09:25:58 -04:00
leangseu-edx
5e716ece2d feat: Remove Learner Home from web crawling (#133) 2023-04-13 11:35:32 -04:00
Ben Warzeski
320f6acc21 fix: show cert for not-passing courses and hide link when missing URL (#131) 2023-04-10 10:07:02 -04:00
Jenkins
af51373e2c chore(i18n): update translations 2023-04-09 12:46:42 -04:00
Mubbshar Anwar
5dd00e9f24 refactor: update API endpoint (#129) 2023-04-06 08:39:27 -04:00
Ben Warzeski
63eaa00ee1 Bw/fix network args (#130) 2023-03-30 10:51:30 -04:00
Leangseu Kim
e25610c66e fix: disable title link on homeUrl undefined 2023-03-23 10:13:52 -04:00
Leangseu Kim
5724d051b2 fix: begin button disable when audit access expire 2023-03-21 16:06:18 -04:00
Leangseu Kim
9a5ac5ddf7 chore: add es, fr_ca, pt_BR translation
chore: remove intl as it is no longer needed

chore: update test snaphot
2023-03-17 13:56:40 -04:00
Leangseu Kim
145c18d9ed fix: credit cors error 2023-03-16 11:32:24 -04:00
Jenkins
b4bb924659 chore(i18n): update translations 2023-03-14 10:42:13 -04:00
Leangseu Kim
45e8113553 chore: empty out translation file 2023-03-13 11:09:26 -04:00
Mashal Malik
cfb9bfdb6b Update transifex api from v2 to v3 (#118) 2023-03-13 10:58:34 -04:00
Nathan Sprenkle
6a73054a9c fix: update links to new MFE experiences in header (#121) 2023-03-08 01:23:32 -05:00
Nathan Sprenkle
5d88e8d1ec fix: update menu item link for variant header (#120) 2023-03-07 18:20:23 -05:00
Nathan Sprenkle
19d7aa3e33 fix: update menu item link for account and profile (#119) 2023-03-07 17:48:34 -05:00
Leangseu Kim
3c7be4c65c fix: disable view course when audit expired 2023-03-03 11:05:00 -05:00
Leangseu Kim
7ccf049edb fix: credit banner link 2023-03-02 15:40:21 -05:00
Mashal Malik
49d70dda93 Migrate from Coveralls to Codecov and removed is5 (#116) 2023-03-02 09:17:31 -05:00
Feanil Patel
6b225cbf86 build: Updating a missing workflow file commitlint.yml.
The .github/workflows/commitlint.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:40:39 -05:00
Feanil Patel
07874fecf7 build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:40:39 -05:00
Feanil Patel
b79c0d1434 build: Creating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:40:39 -05:00
Feanil Patel
63ab012bcb build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:40:39 -05:00
leangseu-edx
f25a15d917 Improve accessibility for learner home (#115) 2023-02-28 09:14:53 -05:00
Matthew Carter
ff631a21c2 feat: add page property to recommendations click event (#105) 2023-02-17 07:49:48 -05:00
Syed Sajjad Hussain Shah
3f801caf38 feat: add page property to recommendations click event
VAN-1261
2023-02-17 09:41:05 +05:00
attiyaishaque
38b0d5832f fix: [VAN-1292] Remove personalized recommendation cookie 2023-02-16 10:11:12 -05:00
Leangseu Kim
33c50082ef chore: make related program folder bolder 2023-02-14 14:18:56 -05:00
leangseu-edx
cba982a7a0 feat: drop support for node 12 & 14 (#111) 2023-02-14 14:01:32 -05:00
leangseu-edx
55de93ef59 feat: add event for filtering (#109) 2023-02-13 14:54:00 -05:00
leangseu-edx
0933d185af feat: update related program location (#104) 2023-02-13 14:12:27 -05:00
Leangseu Kim
a13085a6a1 fix: update hooks action to take arguments 2023-02-13 12:11:50 -05:00
Leangseu Kim
dc3141cc65 feat: pagination to 25 and allow diable_pagination 2023-02-09 13:38:51 -05:00
Ben Warzeski
b2e8621e5c Thunk Removal Proof-of-concept (#98) 2023-01-19 10:13:48 -05:00
Syed Sajjad Hussain Shah
82268b4f37 fix: update recommendation logic to use isControl and keep 5 courses in cookie (#103) 2023-01-17 10:20:36 -05:00
Ben Warzeski
044bf0f45a fix: disable reason select if no reason selected (#101) 2022-12-21 10:47:43 -05:00
Ben Warzeski
166c64a391 feat: credit banner (#96) 2022-12-19 13:25:02 -05:00
Ben Warzeski
a21698e96a fix: disable course title click for unfulfilled entitlements (#99) 2022-12-16 13:03:16 -05:00
Leangseu Kim
41f563dd9a chore: add tracking to the resume click 2022-12-16 12:58:11 -05:00
Ben Warzeski
81f1282cb0 fix: only fire event if dashboard actually opens (#97) 2022-12-15 10:53:04 -05:00
Leangseu Kim
875ecdbdb0 chore: fix overlap explore course button 2022-12-14 13:47:08 -05:00
leangseu-edx
a3106928e1 feat: add explore course link on the header (#94) 2022-12-14 10:55:02 -05:00
leangseu-edx
3b32a5cd16 chore: put hotjar and facebook share button back (#92) 2022-12-13 09:58:35 -05:00
leangseu-edx
b23e741a5e Revert "chore: add facebook button back"
This reverts commit 03e42f45bb.
2022-12-12 11:23:35 -05:00
leangseu-edx
06bd224a20 Revert "feat: adding hotjar support"
This reverts commit 6003099a73.
2022-12-12 11:09:47 -05:00
Leangseu Kim
6003099a73 feat: adding hotjar support 2022-12-09 14:02:03 -05:00
Leangseu Kim
03e42f45bb chore: add facebook button back 2022-12-09 14:00:24 -05:00
Leangseu Kim
d878358984 fix: tracking for entitlement 2022-12-07 16:03:43 -05:00
Ben Warzeski
61bf5f8685 Bw/segment events (#85) 2022-12-05 13:39:42 -05:00
Ben Warzeski
f435e8d2c2 fix: unenroll messaging (#84) 2022-12-02 15:10:11 -05:00
Ben Warzeski
496abc5bfb Bw/fix recc link (#83) 2022-12-02 11:38:24 -05:00
Ben Warzeski
b6c8f7e3b2 Fix: More tracking fixes (#82) 2022-12-02 10:33:52 -05:00
Ben Warzeski
0c0aed00de Fix tracking links (#81) 2022-12-01 14:48:45 -05:00
Ben Warzeski
bbe9b7bb80 fix: recommendations link event (#80) 2022-11-30 16:27:04 -05:00
leangseu-edx
821e6bce50 chore: remove duplicate element (#79)
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2022-11-30 14:12:21 -05:00
Ben Warzeski
82219e9b08 fix: image wrapper (#78) 2022-11-30 14:07:22 -05:00
Ben Warzeski
9a57f9de13 Bw/segment (#76)
Co-authored-by: Leangseu Kim <lkim@edx.org>
2022-11-30 11:01:39 -05:00
Syed Sajjad Hussain Shah
15afb3645f fix: alignment issue between enrolled and recommended courses section
VAN-1187
2022-11-30 09:36:54 -05:00
leangseu-edx
2932693cda feat: add zendesk widget (#71) 2022-11-30 09:33:43 -05:00
Ben Warzeski
552614466a docs: Add README and CODEOWNERS (#68)
Co-authored-by: nsprenkle <nsprenkle@2u.com>
2022-11-22 12:26:24 -05:00
leangseu-edx
25383e3913 lk/fix program url (#73) 2022-11-22 10:43:25 -05:00
Ben Warzeski
fbf812f75c feat: log segment key for debug purposes (revert later!) (#72) 2022-11-18 12:03:41 -05:00
Syed Sajjad Hussain Shah
1ed4bac475 fix: show Popular on edX title for general recs [VAN-1158] (#69) 2022-11-18 10:58:13 -05:00
leangseu-edx
48b157fdd0 feat: tracking upgrade and discovery (#66) 2022-11-10 13:27:04 -05:00
Syed Sajjad Hussain Shah
e92a571cac feat: add amplitude recommendations cookie [VAN-1149] (#65) 2022-11-08 10:37:00 -05:00
leangseu-edx
2a72a85efd chore: responsive course card (#64) 2022-11-07 15:06:09 -05:00
Ben Warzeski
dde8d45df3 Bw/recommendations panel (#63)
Co-authored-by: Shafqat Farhan <shafqat.farhan@arbisoft.com>
2022-11-04 15:01:56 -04:00
Leangseu Kim
b8245d6631 chore: make sure to load initialize after change session 2022-11-02 14:50:59 -04:00
Leangseu Kim
1129ff2847 fix: select and leave session to match the backend 2022-11-02 12:28:31 -04:00
Leangseu Kim
c6432864ab fix: footer and frontend-platform peer deps 2022-11-01 16:55:37 -04:00
Ben Warzeski
c8b729a65d fix: select session workflow (#59) 2022-11-01 14:14:07 -04:00
leangseu-edx
0badf690a6 Lk/update responsive view (#58) 2022-10-31 13:29:24 -04:00
leangseu-edx
41df13b059 lk/resume button url (#56) 2022-10-26 15:22:06 -04:00
Leangseu Kim
254ccfccb6 fix: add handler when url is null 2022-10-25 16:21:21 -04:00
Leangseu Kim
3973d173cf chore: update usage of environment variables 2022-10-25 15:45:14 -04:00
Ben Warzeski
61e484af1f fix: Bug-doc fixes (#52)
Co-authored-by: Leangseu Kim <lkim@edx.org>
2022-10-25 15:36:33 -04:00
Leangseu Kim
f8b181e8c9 feat: add verified ribbon 2022-10-25 14:18:36 -04:00
leangseu-edx
115ef77e37 chore: update course selector (#51) 2022-10-25 13:19:58 -04:00
Ben Warzeski
57eac99b42 feat: add social actions + descriptive names to mock data (#41) 2022-10-24 15:24:43 -04:00
Leangseu Kim
7174f8de1e fix: update transfex flag for tx cli 1.4.0 2022-10-24 10:41:30 -04:00
Ben Warzeski
c16390e157 Bw/design fixes (#49) 2022-10-21 14:59:31 -04:00
Leangseu Kim
a0c0384a3d fix: change email settings 2022-10-21 10:07:57 -04:00
Leangseu Kim
e4900351f8 feat: implement update email setting 2022-10-20 15:15:00 -04:00
Leangseu Kim
0f4e2e28dd chore: fix typo for selection button 2022-10-20 11:58:06 -04:00
Ben Warzeski
4b38aaa199 fix: a11y issues (#47) 2022-10-19 15:01:50 -04:00
leangseu-edx
9abcf35100 fix static urls (#43) 2022-10-18 13:27:13 -04:00
leangseu-edx
66b7a97450 chore: update local port (#42) 2022-10-17 13:33:15 -04:00
395 changed files with 18205 additions and 35391 deletions

11
.env
View File

@@ -2,6 +2,7 @@ NODE_ENV='production'
NODE_PATH=./src
BASE_URL=''
LMS_BASE_URL=''
ECOMMERCE_BASE_URL=''
LOGIN_URL=''
LOGOUT_URL=''
CSRF_TOKEN_API_PATH=''
@@ -30,4 +31,12 @@ ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
LEARNING_MICROFRONTEND_URL=''
LEARNING_BASE_URL=''
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES=''
CAREER_LINK_URL=''

View File

@@ -1,7 +1,8 @@
NODE_ENV='development'
PORT=1993
BASE_URL='localhost:1993'
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
@@ -36,4 +37,13 @@ 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_MICROFRONTEND_URL='http://localhost:2000'
LEARNING_BASE_URL='http://localhost:2000'
SESSION_COOKIE_DOMAIN='localhost'
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995'
ENABLE_NOTICES=''
CAREER_LINK_URL=''

View File

@@ -1,7 +1,8 @@
NODE_ENV='test'
PORT=1993
BASE_URL='localhost:1993'
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
@@ -36,4 +37,12 @@ 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_MICROFRONTEND_URL='http://localhost:2000'
LEARNING_BASE_URL='http://localhost:2000'
ZENDESK_KEY='test-zendesk-key'
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'
ENABLE_NOTICES=''
CAREER_LINK_URL=''

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# Root app is developed and owned by Aurora
* @edx/content-aurora
# WIDGETS and experiments are developed and owned by separate teams below
# Recommendations panel
/src/widgets/RecommendationsPanel @edx/vanguards
/src/widgets/LookingForChallengeWidget @edx/vanguards

View File

@@ -0,0 +1,19 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -11,17 +11,17 @@ on:
jobs:
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [12, 14, 16]
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
@@ -39,7 +39,7 @@ jobs:
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v3
- name: Send failure notification
if: ${{ failure() }}

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -10,14 +10,17 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 12
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.15

View File

@@ -1,20 +1,18 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
export TRANSIFEX_RESOURCE = frontend-app-learner-dashboard
transifex_langs = "ar,fr,fr_CA,es_419,pt_BR,zh_CN"
transifex_resource = frontend-app-learner-dashboard
transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
NPM_TESTS=build i18n_extract lint test
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -49,15 +47,28 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
$(intl_imports) frontend-component-footer frontend-app-learner-dashboard
endif
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# 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/footer components.
## Quickstart
To start the MFE and enable the feature in LMS:
1. Start the MFE with `npm run start`. Take a note of the path/port (defaults to `http://localhost:1996`).
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.
## Contributing
A core goal of this app is to provide a clean experimentation interface. To promote this end, we have provided a silo'ed code directory at `src/widgets` in which contributors should add their custom widget components. In order to ensure our ability to maintain the code stability of the app, the code for these widgets should be strictly contained within the bounds of that directory.
Once written, the widgets can be configured into one of our widget containers at `src/containers/WidgetContainers`. This can include conditional logic, as well as Optimizely triggers. It is important to note that our integration tests will isolate and ignore these containers, and thus testing your widget is the response of the creator/maintainer of the widget itself.
Some guidelines for writing widgets:
* Code for the widget should be strictly confined to the `src/widgets` directory.
* You can load data from the redux store, but should not add or modify fields in that structure.
* Network events should be managed in component hooks, though can use our `data/constants/requests:requestStates` for ease of tracking the request states.
## Resources
* [Learner Home project info at the Open edX Wiki](https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3575906333/Learner+Home)

36538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,13 @@
{
"name": "@edx/frontend-app-learner-dash",
"name": "@edx/frontend-app-learner-dashboard",
"version": "0.0.1",
"description": "",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-learner-dash.git"
"url": "git+https://github.com/edx/frontend-app-learner-dashboard.git"
},
"scripts": {
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
@@ -18,6 +16,7 @@
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"snapshot": "fedx-scripts jest --updateSnapshot",
"prepare": "husky install"
},
"author": "edX",
@@ -29,9 +28,10 @@
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/browserslist-config": "^1.1.0",
"@edx/frontend-component-footer": "11.3.1",
"@edx/frontend-platform": "3.0.0",
"@edx/paragon": "20.12.0",
"@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-enterprise-hotjar": "^1.2.0",
"@edx/frontend-platform": "^4.2.0",
"@edx/paragon": "^20.32.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
@@ -58,12 +58,13 @@
"query-string": "7.0.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"react-router-dom": "5.3.3",
"react-share": "^4.4.0",
"react-zendesk": "^0.1.13",
"redux": "4.1.1",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "2.13.9",
@@ -71,25 +72,25 @@
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.9",
"reselect": "^4.0.0",
"universal-cookie": "^4.0.4",
"util": "^0.12.4",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/frontend-build": "11.0.1",
"@edx/frontend-build": "12.8.27",
"@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"axios-mock-adapter": "^1.20.0",
"codecov": "^3.8.3",
"copy-webpack-plugin": "^11.0.0",
"enzyme-adapter-react-16": "^1.15.6",
"es-check": "^6.0.0",
"fetch-mock": "^9.11.0",
"husky": "^7.0.0",
"identity-obj-proxy": "^3.0.0",
"jest-expect-message": "^1.0.2",
"react-dev-utils": "^11.0.4",
"react-test-renderer": "^16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.4",
"semantic-release": "^17.4.5"
"semantic-release": "^20.1.3"
}
}

2
public/robots.txt Normal file
View File

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

View File

@@ -1,45 +1,93 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Helmet } from 'react-helmet';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
import Footer from '@edx/frontend-component-footer';
import { Alert } from '@edx/paragon';
import { thunkActions } from 'data/redux';
import fakeData from 'data/services/lms/fakeData/courses';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { RequestKeys } from 'data/constants/requests';
import store from 'data/store';
import {
selectors,
actions,
} from 'data/redux';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab';
import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
import LearnerDashboardHeader from './containers/LearnerDashboardHeader';
import messages from './messages';
import './App.scss';
export const App = () => {
const dispatch = useDispatch();
// TODO: made development-only
const { authenticatedUser } = React.useContext(AppContext);
const { formatMessage } = useIntl();
const isFailed = {
initialize: reduxHooks.useRequestIsFailed(RequestKeys.initialize),
refreshList: reduxHooks.useRequestIsFailed(RequestKeys.refreshList),
};
const hasNetworkFailure = isFailed.initialize || isFailed.refreshList;
const { supportEmail } = reduxHooks.usePlatformSettingsData();
const loadData = reduxHooks.useLoadData();
React.useEffect(() => {
if (authenticatedUser?.administrator || process.env.NODE_ENV === 'development') {
window.loadEmptyData = () => {
dispatch(thunkActions.app.loadData({ ...fakeData.globalData, courses: [] }));
loadData({ ...fakeData.globalData, courses: [] });
};
window.loadMockData = () => {
dispatch(thunkActions.app.loadData({
loadData({
...fakeData.globalData,
courses: [
...fakeData.courseRunData,
...fakeData.entitlementData,
],
}));
});
};
window.store = store;
window.selectors = selectors;
window.actions = actions;
window.track = track;
}
});
if (process.env.HOTJAR_APP_ID) {
try {
initializeHotjar({
hotjarId: process.env.HOTJAR_APP_ID,
hotjarVersion: process.env.HOTJAR_VERSION,
hotjarDebug: !!process.env.HOTJAR_DEBUG,
});
} catch (error) {
logError(error);
}
}
}, [authenticatedUser, loadData]);
return (
<Router>
<Helmet>
<title>{formatMessage(messages.pageTitle)}</title>
</Helmet>
<div>
<LearnerDashboardHeader />
<main>
<Dashboard />
{hasNetworkFailure
? (
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (<Dashboard />)}
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab />
</div>
</Router>
);

View File

@@ -11,7 +11,13 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
@import "~@edx/frontend-component-footer/dist/_footer";
.alert .alert-icon {
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert.alert-info .alert-icon {
color: black;
}

View File

@@ -1,41 +1,111 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Helmet } from 'react-helmet';
import { ErrorPage } from '@edx/frontend-platform/react';
import { BrowserRouter } from 'react-router-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { App } from './App';
import messages from './messages';
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
thunkActions: 'redux.thunkActions',
}));
jest.mock('hooks', () => ({
reduxHooks: {
useRequestIsFailed: jest.fn(),
usePlatformSettingsData: jest.fn(),
useLoadData: jest.fn(),
},
}));
jest.mock('data/store', () => 'data/store');
const loadData = jest.fn();
reduxHooks.useLoadData.mockReturnValue(loadData);
const logo = 'fakeLogo.png';
let el;
let router;
const supportEmail = 'test-support-url';
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
describe('App router component', () => {
test('snapshot: enabled', () => {
expect(shallow(<App />)).toMatchSnapshot();
});
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
const { formatMessage } = useIntl();
describe('component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.find(BrowserRouter);
});
describe('Router', () => {
test('Routing - ListView is only route', () => {
expect(router.find('main')).toEqual(shallow(
const runBasicTests = () => {
test('snapshot', () => { expect(el).toMatchSnapshot(); });
it('displays title in helmet component', () => {
expect(el.find(Helmet).find('title').text()).toEqual(useIntl().formatMessage(messages.pageTitle));
});
it('displays learner dashboard header', () => {
expect(el.find(LearnerDashboardHeader).length).toEqual(1);
});
it('wraps the page in a browser router', () => {
expect(el.find(Router)).toMatchObject(el);
});
test('Footer logo drawn from env variable', () => {
expect(el.find(Footer).props().logo).toEqual(logo);
});
};
describe('no network failure', () => {
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockReturnValue(false);
el = shallow(<App />);
});
runBasicTests();
it('loads dashboard', () => {
expect(el.find('main')).toMatchObject(shallow(
<main><Dashboard /></main>,
));
});
});
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
describe('initialize failure', () => {
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize);
el = shallow(<App />);
});
runBasicTests();
it('loads error page', () => {
expect(el.find('main')).toEqual(shallow(
<main>
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
</main>,
));
});
});
describe('refresh failure', () => {
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList);
el = shallow(<App />);
});
runBasicTests();
it('loads error page', () => {
expect(el.find('main')).toEqual(shallow(
<main>
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
</main>,
));
});
});
});
});

View File

@@ -1,15 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot: enabled 1`] = `
exports[`App router component component initialize failure snapshot 1`] = `
<BrowserRouter>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
`;
exports[`App router component component no network failure snapshot 1`] = `
<BrowserRouter>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeader />
<main>
<Dashboard />
</main>
<Footer
logo="https://edx-cdn.org/v3/stage/open-edx-tag.svg"
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
`;
exports[`App router component component refresh failure snapshot 1`] = `
<BrowserRouter>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
`;

View File

@@ -7,17 +7,24 @@ exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPag
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<IntlProvider
locale="en"
>
<AppProvider
store={
Object {
"redux": "store",
}
<AppProvider
store={
Object {
"redux": "store",
}
>
<App />
</AppProvider>
</IntlProvider>
}
>
<NoticesWrapper>
<Switch>
<PageRoute
path="/"
>
<App />
</PageRoute>
<Redirect
to="/"
/>
</Switch>
</NoticesWrapper>
</AppProvider>
`;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -4,19 +4,23 @@ import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
export const Banner = ({ children, variant, icon }) => (
<Alert variant={variant} className="mb-0" icon={icon}>
export const Banner = ({
children, variant, icon, className,
}) => (
<Alert variant={variant} className={className} icon={icon}>
{children}
</Alert>
);
Banner.defaultProps = {
icon: Info,
variant: 'info',
className: 'mb-0',
};
Banner.propTypes = {
variant: PropTypes.string,
icon: PropTypes.func,
children: PropTypes.node.isRequired,
className: PropTypes.string,
};
export default Banner;

View File

@@ -19,5 +19,9 @@ describe('Banner', () => {
expect(wrapper.find(Alert).prop('variant')).toEqual('success');
});
test('renders with custom class', () => {
const wrapper = shallow(<Banner {...props} className="custom-class" />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MailtoLink } from '@edx/paragon';
export const EmailLink = ({ address }) => {
if (!address) {
return null;
}
return (
<MailtoLink to={address}>{address}</MailtoLink>
);
};
EmailLink.defaultProps = { address: null };
EmailLink.propTypes = { address: PropTypes.string };
export default EmailLink;

View File

@@ -0,0 +1,16 @@
import { shallow } from 'enzyme';
import EmailLink from './EmailLink';
describe('EmailLink', () => {
it('renders null when no address is provided', () => {
const wrapper = shallow(<EmailLink />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.isEmptyRender()).toEqual(true);
});
it('renders a MailtoLink when an address is provided', () => {
const wrapper = shallow(<EmailLink address="test@email.com" />);
expect(wrapper.find('MailtoLink').length).toEqual(1);
expect(wrapper).toMatchSnapshot();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { shallow } from 'enzyme';
import useNoticesWrapperData from './hooks';
import NoticesWrapper from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = { isRedirected: false };
useNoticesWrapperData.mockReturnValue(hookProps);
let el;
const children = [<b key={1}>some</b>, <i key={2}>children</i>];
describe('NoticesWrapper component', () => {
describe('behavior', () => {
it('initializes hooks', () => {
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(useNoticesWrapperData).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('does not show children if redirected', () => {
useNoticesWrapperData.mockReturnValueOnce({ isRedirected: true });
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(el.children().length).toEqual(0);
});
it('shows children if not redirected', () => {
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(el.children().length).toEqual(2);
expect(el.children().at(0).matchesElement(children[0])).toEqual(true);
expect(el.children().at(1).matchesElement(children[1])).toEqual(true);
});
});
});

View File

@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ZendeskFab snapshot 1`] = `
<Zendesk
cookies={true}
defer={true}
webWidget={
Object {
"answerBot": Object {
"avatar": Object {
"name": Object {
"*": "edX Support",
},
"url": "https://edx-cdn.org/v3/prod/favicon.ico",
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": Object {
"*": "edX Support",
},
},
"chat": Object {
"departments": Object {
"enabled": Array [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": Object {
"attachments": true,
"selectTicketForm": Object {
"*": "Please choose your request type:",
},
"ticketForms": Array [
Object {
"fields": Array [
Object {
"id": "description",
"prefill": Object {
"*": "",
},
},
],
"id": 360003368814,
"subject": false,
},
],
},
"contactOptions": Object {
"enabled": false,
},
"helpCenter": Object {
"originalArticleButton": true,
},
}
}
/>
`;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';
import messages from './messages';
const ZendeskFab = () => {
const { formatMessage } = useIntl();
const setting = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [{ id: 'description', prefill: { '*': '' } }],
},
],
selectTicketForm: {
'*': formatMessage(messages.selectTicketForm),
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': formatMessage(messages.supportTitle) },
avatar: {
url: 'https://edx-cdn.org/v3/prod/favicon.ico',
name: { '*': formatMessage(messages.supportTitle) },
},
},
},
};
return (
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
);
};
export default ZendeskFab;

View File

@@ -0,0 +1,12 @@
import { shallow } from 'enzyme';
import ZendeskFab from '.';
jest.mock('react-zendesk', () => 'Zendesk');
describe('ZendeskFab', () => {
test('snapshot', () => {
const wrapper = shallow(<ZendeskFab />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,16 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
supportTitle: {
id: 'zendesk.supportTitle',
description: 'Title for the support button',
defaultMessage: 'edX Support',
},
selectTicketForm: {
id: 'zendesk.selectTicketForm',
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
});
export default messages;

View File

@@ -10,6 +10,16 @@ exports[`Banner snapshot renders default banner 1`] = `
</Alert>
`;
exports[`Banner snapshot renders with custom class 1`] = `
<Alert
className="custom-class"
icon={[MockFunction icons.Info]}
variant="info"
>
Hello, world!
</Alert>
`;
exports[`Banner snapshot renders with variants 1`] = `
<Alert
className="mb-0"

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmailLink renders a MailtoLink when an address is provided 1`] = `
<MailtoLink
to="test@email.com"
>
test@email.com
</MailtoLink>
`;
exports[`EmailLink renders null when no address is provided 1`] = `""`;

View File

@@ -1,15 +1,21 @@
const configuration = {
// BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_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,
SEGMENT_KEY: process.env.SEGMENT_KEY,
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
LEARNING_MICROFRONTEND_URL: process.env.LEARNING_MICROFRONTEND_URL,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
SUPPORT_URL: process.env.SUPPORT_URL || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
};
const features = {};

View File

@@ -2,20 +2,74 @@
.course-card {
.card {
.pgn__card-wrapper-image-cap.vertical {
display: flex;
min-height: $card-image-vertical-max-height;
}
.pgn__card-image-cap {
border-bottom-left-radius: 0 !important;
}
.overflow-visible {
overflow: visible;
}
.pgn__card-header-content {
margin-top: 1.5rem;
}
.pgn__card-footer {
flex-wrap: nowrap;
&.vertical {
flex-direction: column;
}
.pgn__action-row {
align-self: flex-end;
white-space: nowrap;
}
}
.course-card-verify-ribbon-container {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
text-align: center;
.badge {
border-radius: 0;
}
> img {
width: 40px;
z-index: 1000;
position: absolute;
top: 0;
right: 0;
}
}
}
.course-card-banners {
> .alert {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-radius: 0;
box-shadow: none;
padding: map-get($spacers, 3) map-get($spacers, 4);
&:last-of-type {
border-bottom-left-radius: $alert-border-radius;
border-bottom-right-radius: $alert-border-radius;
}
}
.related-programs-banner {
.related-programs-list-container {
list-style: none;
display: inline;
> li {
line-height: 1rem;
}
}
}
}
}

View File

@@ -13,19 +13,42 @@ exports[`CourseCard component snapshot: collapsed 1`] = `
className="d-flex flex-column w-100"
>
<div>
<CourseCardContent
cardId="test-card-id"
orientation="vertical"
/>
</div>
<div
className="course-card-banners"
data-testid="CourseCardBanners"
>
<CourseCardBanners
<CourseCardImage
cardId="test-card-id"
orientation="horizontal"
/>
<Card.Body>
<Card.Header
actions={
<CourseCardMenu
cardId="test-card-id"
/>
}
title={
<CourseCardTitle
cardId="test-card-id"
/>
}
/>
<Card.Section
className="pt-0"
>
<CourseCardDetails
cardId="test-card-id"
/>
</Card.Section>
<Card.Footer
orientation="vertical"
>
<CourseCardActions
cardId="test-card-id"
/>
</Card.Footer>
</Card.Body>
</div>
<CourseCardBanners
cardId="test-card-id"
/>
</div>
</Card>
</div>
@@ -46,19 +69,42 @@ exports[`CourseCard component snapshot: not collapsed 1`] = `
<div
className="d-flex"
>
<CourseCardContent
<CourseCardImage
cardId="test-card-id"
orientation="horizontal"
/>
<Card.Body>
<Card.Header
actions={
<CourseCardMenu
cardId="test-card-id"
/>
}
title={
<CourseCardTitle
cardId="test-card-id"
/>
}
/>
<Card.Section
className="pt-0"
>
<CourseCardDetails
cardId="test-card-id"
/>
</Card.Section>
<Card.Footer
orientation="horizontal"
>
<CourseCardActions
cardId="test-card-id"
/>
</Card.Footer>
</Card.Body>
</div>
<div
className="course-card-banners"
data-testid="CourseCardBanners"
>
<CourseCardBanners
cardId="test-card-id"
/>
</div>
<CourseCardBanners
cardId="test-card-id"
/>
</div>
</Card>
</div>

View File

@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ActionButton snapshot is collapsed 1`] = `
<Button
arbitary="props"
size="sm"
/>
`;
exports[`ActionButton snapshot is not collapsed 1`] = `
<Button
arbitary="props"
/>
`;

View File

@@ -0,0 +1,8 @@
import { useWindowSize, breakpoints } from '@edx/paragon';
export const useIsCollapsed = () => {
const { width } = useWindowSize();
return width < breakpoints.medium.maxWidth && width > breakpoints.small.maxWidth;
};
export default useIsCollapsed;

View File

@@ -0,0 +1,21 @@
import { useWindowSize, breakpoints } from '@edx/paragon';
import useIsCollapsed from './hooks';
describe('useIsCollapsed', () => {
it('returns true only when it is between medium and small', () => {
// make sure all three breakpoints gap is large enough for test
expect(
(breakpoints.large.maxWidth - 1)
> (breakpoints.medium.maxWidth - 1)
&& (breakpoints.medium.maxWidth - 1)
> (breakpoints.small.maxWidth - 1),
).toBe(true);
useWindowSize.mockReturnValue({ width: breakpoints.large.maxWidth - 1 });
expect(useIsCollapsed()).toEqual(false);
useWindowSize.mockReturnValue({ width: breakpoints.medium.maxWidth - 1 });
expect(useIsCollapsed()).toEqual(true);
useWindowSize.mockReturnValue({ width: breakpoints.small.maxWidth - 1 });
expect(useIsCollapsed()).toEqual(false);
});
});

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { Button } from '@edx/paragon';
import useIsCollapsed from './hooks';
export const ActionButton = (props) => {
const isSmall = useIsCollapsed();
return (
<Button
{...props}
{...isSmall && { size: 'sm' }}
/>
);
};
export default ActionButton;

View File

@@ -0,0 +1,25 @@
import { shallow } from 'enzyme';
import ActionButton from '.';
import useIsCollapsed from './hooks';
jest.mock('./hooks', () => jest.fn());
describe('ActionButton', () => {
const props = {
arbitary: 'props',
};
describe('snapshot', () => {
test('is collapsed', () => {
useIsCollapsed.mockReturnValueOnce(true);
const wrapper = shallow(<ActionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('is not collapsed', () => {
useIsCollapsed.mockReturnValueOnce(false);
const wrapper = shallow(<ActionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -1,25 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const BeginCourseButton = ({ cardId }) => {
const { homeUrl } = hooks.useCardCourseRunData(cardId);
const { hasAccess } = hooks.useCardEnrollmentData(cardId);
const { isMasquerading } = hooks.useMasqueradeData();
const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl,
);
return (
<Button
disabled={isMasquerading || !hasAccess}
<ActionButton
disabled={disableBeginCourse}
as="a"
href={homeUrl}
href="#"
onClick={handleClick}
>
{formatMessage(messages.beginCourse)}
</Button>
</ActionButton>
);
};
BeginCourseButton.propTypes = {

View File

@@ -1,19 +1,30 @@
import { shallow } from 'enzyme';
import { htmlProps } from 'data/constants/htmlKeys';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
import BeginCourseButton from './BeginCourseButton';
jest.mock('data/redux', () => ({
hooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'home-url' })),
useCardEnrollmentData: jest.fn(() => ({ hasAccess: true })),
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
jest.mock('tracking', () => ({
course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'home-url' })),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
let wrapper;
const { homeUrl } = hooks.useCardCourseRunData();
const { homeUrl } = reduxHooks.useCardCourseRunData();
describe('BeginCourseButton', () => {
const props = {
@@ -27,29 +38,22 @@ describe('BeginCourseButton', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.href)).toEqual(homeUrl);
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
homeUrl,
));
});
});
describe('behavior', () => {
it('initializes course run data with cardId', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(hooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('initializes enrollment data with cardId', () => {
test('disabled states', () => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
wrapper = shallow(<BeginCourseButton {...props} />);
expect(hooks.useCardEnrollmentData).toHaveBeenCalledWith(props.cardId);
});
describe('disabled states', () => {
test('learner does not have access', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('masquerading', () => {
hooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
});

View File

@@ -1,25 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const ResumeButton = ({ cardId }) => {
const { resumeUrl } = hooks.useCardCourseRunData(cardId);
const { hasAccess, isAudit, isAuditAccessExpired } = hooks.useCardEnrollmentData(cardId);
const { isMasquerading } = hooks.useMasqueradeData();
const { formatMessage } = useIntl();
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
resumeUrl,
);
return (
<Button
disabled={isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)}
<ActionButton
disabled={disableResumeCourse}
as="a"
href={resumeUrl}
href="#"
onClick={handleClick}
>
{formatMessage(messages.resume)}
</Button>
</ActionButton>
);
};
ResumeButton.propTypes = {

View File

@@ -1,22 +1,28 @@
import { shallow } from 'enzyme';
import { htmlProps } from 'data/constants/htmlKeys';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
import ResumeButton from './ResumeButton';
jest.mock('data/redux', () => ({
hooks: {
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ resumeUrl: 'resumeUrl' })),
useCardEnrollmentData: jest.fn(() => ({
hasAccess: true,
isAudit: true,
isAuditAccessExpired: false,
})),
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
useTrackCourseEvent: (eventName, cardId, url) => jest
.fn()
.mockName(`useTrackCourseEvent('${eventName}', '${cardId}', '${url}')`),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
jest.mock('tracking', () => ({
course: {
enterCourseClicked: 'enterCourseClicked',
},
}));
jest.mock('./ActionButton', () => 'ActionButton');
const { resumeUrl } = hooks.useCardCourseRunData();
const { resumeUrl } = reduxHooks.useCardCourseRunData();
describe('ResumeButton', () => {
const props = {
@@ -27,42 +33,25 @@ describe('ResumeButton', () => {
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.href)).toEqual(resumeUrl);
expect(wrapper.prop(htmlProps.onClick).getMockName()).toContain(
'useTrackCourseEvent',
track.course.enterCourseClicked,
props.cardId,
resumeUrl,
);
});
});
describe('behavior', () => {
it('initializes course run data based on cardId', () => {
shallow(<ResumeButton {...props} />);
expect(hooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(
props.cardId,
);
});
it('initializes course enrollment data based on cardId', () => {
shallow(<ResumeButton {...props} />);
expect(hooks.useCardEnrollmentData).toHaveBeenCalledWith(props.cardId);
});
describe('disabled states', () => {
test('masquerading', () => {
hooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('learner does not have access', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({
hasAccess: false,
isAudit: true,
isAuditAccessExpired: false,
});
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('audit access expired', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({
hasAccess: true,
isAudit: true,
isAuditAccessExpired: true,
});
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('disabled states', () => {
useActionDisabledState.mockReturnValueOnce({ disableResumeCourse: true });
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
});

View File

@@ -1,27 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const SelectSessionButton = ({ cardId }) => {
const { hasAccess } = hooks.useCardEnrollmentData(cardId);
const { canChange, hasSessions } = hooks.useCardEntitlementData(cardId);
const { isMasquerading } = hooks.useMasqueradeData();
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const openSessionModal = hooks.useUpdateSelectSessionModalCallback(dispatch, cardId);
const { disableSelectSession } = useActionDisabledState(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
return (
<Button
disabled={isMasquerading || !hasAccess || (!canChange || !hasSessions)}
<ActionButton
disabled={disableSelectSession}
onClick={openSessionModal}
>
{formatMessage(messages.resume)}
</Button>
{formatMessage(messages.selectSession)}
</ActionButton>
);
};
SelectSessionButton.propTypes = {

View File

@@ -1,66 +1,35 @@
import { shallow } from 'enzyme';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import useActionDisabledState from '../hooks';
import SelectSessionButton from './SelectSessionButton';
jest.mock('data/redux', () => ({
hooks: {
useCardEnrollmentData: jest.fn(() => ({ hasAccess: true })),
useCardEntitlementData: jest.fn(() => ({ canChange: true, hasSessions: true })),
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
jest.mock('hooks', () => ({
reduxHooks: {
useUpdateSelectSessionModalCallback: () => jest.fn().mockName('mockOpenSessionModal'),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false })));
jest.mock('./ActionButton', () => 'ActionButton');
let wrapper;
describe('SelectSessionButton', () => {
const props = { cardId: 'cardId' };
describe('snapshot', () => {
test('renders default button', () => {
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
it('renders disabled button when user does not have access to the course', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
it('renders disabled button if masquerading', () => {
hooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
it('default render', () => {
expect(wrapper).toMatchSnapshot();
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick).getMockName()).toEqual(
reduxHooks.useUpdateSelectSessionModalCallback().getMockName(),
);
});
describe('behavior', () => {
it('default render', () => {
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick).getMockName())
.toEqual(hooks.useUpdateSelectSessionModalCallback().getMockName());
});
describe('disabled states', () => {
test('learner does not have access', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('learner cannot change sessions', () => {
hooks.useCardEntitlementData.mockReturnValueOnce({ canChange: false, hasSessions: true });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('entitlement does not have available sessions', () => {
hooks.useCardEntitlementData.mockReturnValueOnce({ canChange: true, hasSessions: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('user is masquerading', () => {
hooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
test('disabled states', () => {
useActionDisabledState.mockReturnValueOnce({ disableSelectSession: true });
expect(wrapper).toMatchSnapshot();
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});

View File

@@ -1,28 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { Locked } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const UpgradeButton = ({ cardId }) => {
const { upgradeUrl } = hooks.useCardCourseRunData(cardId);
const { canUpgrade } = hooks.useCardEnrollmentData(cardId);
const { isMasquerading } = hooks.useMasqueradeData();
const { formatMessage } = useIntl();
const isEnabled = (!isMasquerading && canUpgrade);
const { upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableUpgradeCourse } = useActionDisabledState(cardId);
const trackUpgradeClick = reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
cardId,
upgradeUrl,
);
const enabledProps = {
as: 'a',
href: upgradeUrl,
onClick: trackUpgradeClick,
};
return (
<Button
<ActionButton
iconBefore={Locked}
variant="outline-primary"
disabled={!isEnabled}
{...isEnabled && { as: 'a', href: upgradeUrl }}
disabled={disableUpgradeCourse}
{...!disableUpgradeCourse && enabledProps}
>
{formatMessage(messages.upgrade)}
</Button>
</ActionButton>
);
};
UpgradeButton.propTypes = {

View File

@@ -1,37 +1,47 @@
import { shallow } from 'enzyme';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import { hooks } from 'data/redux';
import useActionDisabledState from '../hooks';
import UpgradeButton from './UpgradeButton';
jest.mock('data/redux', () => ({
hooks: {
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(() => ({ canUpgrade: true })),
jest.mock('tracking', () => ({
course: {
upgradeClicked: jest.fn().mockName('segment.trackUpgradeClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableUpgradeCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
describe('UpgradeButton', () => {
const props = {
cardId: 'cardId',
};
const upgradeUrl = 'upgradeUrl';
hooks.useCardCourseRunData.mockReturnValue({ upgradeUrl });
reduxHooks.useCardCourseRunData.mockReturnValue({ upgradeUrl });
describe('snapshot', () => {
test('can upgrade', () => {
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
props.cardId,
upgradeUrl,
));
});
test('cannot upgrade', () => {
hooks.useCardEnrollmentData.mockReturnValueOnce({ canUpgrade: false });
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('masquerading', () => {
hooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
useActionDisabledState.mockReturnValueOnce({ disableUpgradeCourse: true });
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);

View File

@@ -1,25 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const ViewCourseButton = ({ cardId }) => {
const { homeUrl } = hooks.useCardCourseRunData(cardId);
const { hasAccess } = hooks.useCardEnrollmentData(cardId);
const { isEntitlement, isExpired } = hooks.useCardEntitlementData(cardId);
const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableViewCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl,
);
return (
<Button
disabled={!hasAccess || (isEntitlement && isExpired)}
<ActionButton
disabled={disableViewCourse}
as="a"
href={homeUrl}
href="#"
onClick={handleClick}
>
{formatMessage(messages.viewCourse)}
</Button>
</ActionButton>
);
};
ViewCourseButton.propTypes = {

View File

@@ -1,58 +1,46 @@
import { shallow } from 'enzyme';
import track from 'tracking';
import { htmlProps } from 'data/constants/htmlKeys';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ViewCourseButton from './ViewCourseButton';
jest.mock('data/redux', () => ({
hooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
jest.mock('tracking', () => ({
course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
const defaultProps = { cardId: 'cardId' };
const homeUrl = 'homeUrl';
describe('ViewCourseButton', () => {
const props = {
cardId: 'cardId',
};
const homeUrl = 'homeUrl';
hooks.useCardCourseRunData.mockReturnValue({ homeUrl });
const createWrapper = ({
hasAccess = false,
isEntitlement = false,
isExpired = false,
}) => {
hooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess });
hooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isExpired });
return shallow(<ViewCourseButton {...props} />);
};
describe('snapshot', () => {
test('default button', () => {
const wrapper = createWrapper({ hasAccess: true });
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.href)).toEqual(homeUrl);
});
test('disabled button', () => {
const wrapper = createWrapper({});
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
expect(wrapper.prop(htmlProps.href)).toEqual(homeUrl);
});
test('learner can view course', () => {
const wrapper = shallow(<ViewCourseButton {...defaultProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
defaultProps.cardId,
homeUrl,
));
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
describe('behavior', () => {
it('disabled button without access', () => {
const wrapper = createWrapper({ hasAccess: false, isEntitlement: false, isExpired: false });
expect(wrapper.prop('disabled')).toEqual(true);
});
it('disabled button with access', () => {
const wrapper = createWrapper({ hasAccess: true, isEntitlement: true, isExpired: true });
expect(wrapper.prop('disabled')).toEqual(true);
});
it('enabled button', () => {
const wrapper = createWrapper({ hasAccess: true, isEntitlement: false, isExpired: false });
expect(wrapper.prop('disabled')).toEqual(false);
});
test('learner cannot view course', () => {
useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true });
const wrapper = shallow(<ViewCourseButton {...defaultProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});

View File

@@ -1,11 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BeginCourseButton snapshot renders default button when learner has access to the course 1`] = `
<Button
<ActionButton
as="a"
disabled={false}
href="home-url"
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-url",
},
}
}
>
Begin Course
</Button>
</ActionButton>
`;

View File

@@ -1,11 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResumeButton snapshot renders default button when learner has access to the course 1`] = `
<Button
<ActionButton
as="a"
disabled={false}
href="resumeUrl"
href="#"
onClick={[MockFunction useTrackCourseEvent('enterCourseClicked', 'cardId', 'resumeUrl')]}
>
Resume
</Button>
</ActionButton>
`;

View File

@@ -1,28 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectSessionButton snapshot renders default button 1`] = `
<Button
exports[`SelectSessionButton default render 1`] = `undefined`;
exports[`SelectSessionButton disabled states 1`] = `
<ActionButton
disabled={false}
onClick={[MockFunction mockOpenSessionModal]}
>
Resume
</Button>
`;
exports[`SelectSessionButton snapshot renders disabled button if masquerading 1`] = `
<Button
disabled={true}
onClick={[MockFunction mockOpenSessionModal]}
>
Resume
</Button>
`;
exports[`SelectSessionButton snapshot renders disabled button when user does not have access to the course 1`] = `
<Button
disabled={true}
onClick={[MockFunction mockOpenSessionModal]}
>
Resume
</Button>
Select Session
</ActionButton>
`;

View File

@@ -1,33 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpgradeButton snapshot can upgrade 1`] = `
<Button
<ActionButton
as="a"
disabled={false}
href="upgradeUrl"
iconBefore={[MockFunction icons.Locked]}
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.trackUpgradeClicked],
"upgradeUrl": "upgradeUrl",
},
}
}
variant="outline-primary"
>
Upgrade
</Button>
</ActionButton>
`;
exports[`UpgradeButton snapshot cannot upgrade 1`] = `
<Button
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
variant="outline-primary"
>
Upgrade
</Button>
`;
exports[`UpgradeButton snapshot masquerading 1`] = `
<Button
disabled={true}
iconBefore={[MockFunction icons.Locked]}
variant="outline-primary"
>
Upgrade
</Button>
</ActionButton>
`;

View File

@@ -1,21 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ViewCourseButton snapshot default button 1`] = `
<Button
exports[`ViewCourseButton learner can view course 1`] = `
<ActionButton
as="a"
disabled={false}
href="homeUrl"
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</Button>
</ActionButton>
`;
exports[`ViewCourseButton snapshot disabled button 1`] = `
<Button
exports[`ViewCourseButton learner cannot view course 1`] = `
<ActionButton
as="a"
disabled={true}
href="homeUrl"
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</Button>
</ActionButton>
`;

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { ActionRow } from '@edx/paragon';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
@@ -12,9 +12,9 @@ import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => {
const { isEntitlement, isFulfilled } = hooks.useCardEntitlementData(cardId);
const { isVerified, hasStarted } = hooks.useCardEnrollmentData(cardId);
const { isArchived } = hooks.useCardCourseRunData(cardId);
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const { isVerified, hasStarted } = reduxHooks.useCardEnrollmentData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
let PrimaryButton;
if (isEntitlement) {
PrimaryButton = isFulfilled ? ViewCourseButton : SelectSessionButton;

View File

@@ -1,10 +1,11 @@
import { shallow } from 'enzyme';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import CourseCardActions from '.';
jest.mock('data/redux', () => ({
hooks: {
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
@@ -24,9 +25,9 @@ describe('CourseCardActions', () => {
const createWrapper = ({
isEntitlement, isFulfilled, isArchived, isVerified, hasStarted,
}) => {
hooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
hooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
hooks.useCardEnrollmentData.mockReturnValueOnce({ isVerified, hasStarted });
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isVerified, hasStarted });
return shallow(<CourseCardActions {...props} />);
};
describe('snapshot', () => {

View File

@@ -6,22 +6,25 @@ import { MailtoLink, Hyperlink } from '@edx/paragon';
import { CheckCircle } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import { utilHooks, reduxHooks } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
const { useFormatDate } = utilHooks;
export const CertificateBanner = ({ cardId }) => {
const certificate = appHooks.useCardCertificateData(cardId);
const certificate = reduxHooks.useCardCertificateData(cardId);
const {
isAudit,
isVerified,
hasFinished,
} = appHooks.useCardEnrollmentData(cardId);
const { isPassing } = appHooks.useCardGradeData(cardId);
const { minPassingGrade, progressUrl } = appHooks.useCardCourseRunData(cardId);
const { supportEmail, billingEmail } = appHooks.usePlatformSettingsData();
const { formatMessage, formatDate } = useIntl();
} = reduxHooks.useCardEnrollmentData(cardId);
const { isPassing } = reduxHooks.useCardGradeData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId);
const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData();
const { formatMessage } = useIntl();
const formatDate = useFormatDate();
const emailLink = address => address && <MailtoLink to={address}>{address}</MailtoLink>;
@@ -37,6 +40,21 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (certificate.isDownloadable) {
return (
<Banner variant="success" icon={CheckCircle}>
{formatMessage(messages.certReady)}
{certificate.certPreviewUrl && (
<>
{' '}
<Hyperlink isInline destination={certificate.certPreviewUrl}>
{formatMessage(messages.viewCertificate)}
</Hyperlink>
</>
)}
</Banner>
);
}
if (!isPassing) {
if (isAudit) {
return (
@@ -45,12 +63,12 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (hasFinished) {
if (isArchived) {
return (
<Banner variant="warning">
{formatMessage(messages.notEligibleForCert)}.
{formatMessage(messages.notEligibleForCert)}
{' '}
<Hyperlink destination={progressUrl}>{formatMessage(messages.viewGrades)}</Hyperlink>
<Hyperlink isInline destination={progressUrl}>{formatMessage(messages.viewGrades)}</Hyperlink>
</Banner>
);
}
@@ -60,17 +78,6 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (certificate.isDownloadable) {
return (
<Banner variant="success" icon={CheckCircle}>
{formatMessage(messages.certReady)}
{' '}
<Hyperlink destination={certificate.certPreviewUrl}>
{formatMessage(messages.viewCertificate)}
</Hyperlink>
</Banner>
);
}
if (certificate.isEarnedButUnavailable) {
return (
<Banner>

View File

@@ -1,35 +1,37 @@
import { shallow } from 'enzyme';
import { hooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import CertificateBanner from './CertificateBanner';
import messages from './messages';
jest.mock('data/redux', () => ({
hooks: {
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: jest.fn(() => date => date),
},
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardGradeData: jest.fn(),
useCardCourseRunData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
jest.mock('Components/Banner', () => 'Banner');
jest.mock('components/Banner', () => 'Banner');
describe('CertificateBanner', () => {
const props = {
cardId: 'cardId',
};
hooks.usePlatformSettingsData.mockReturnValue({
const props = { cardId: 'cardId' };
reduxHooks.usePlatformSettingsData.mockReturnValue({
supportEmail: 'suport@email',
billingEmail: 'billing@email',
});
hooks.useCardCourseRunData.mockReturnValue({
reduxHooks.useCardCourseRunData.mockReturnValue({
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
});
const defaultCertificate = {
availableDate: '10/20/3030',
isRestricted: false,
isDownloadable: false,
isEarnedButUnavailable: false,
@@ -37,21 +39,22 @@ describe('CertificateBanner', () => {
const defaultEnrollment = {
isAudit: false,
isVerified: false,
hasFinished: false,
};
const defaultGrade = {
isPassing: false,
};
const defaultCourseRun = { isArchived: false };
const defaultGrade = { isPassing: false };
const createWrapper = ({
certificate = {},
enrollment = {},
grade = {},
courseRun = {},
}) => {
hooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade });
hooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate });
hooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment });
reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade });
reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun });
return shallow(<CertificateBanner {...props} />);
};
/** TODO: Update tests to validate snapshots **/
describe('snapshot', () => {
test('is restricted', () => {
const wrapper = createWrapper({
@@ -72,6 +75,20 @@ describe('CertificateBanner', () => {
});
expect(wrapper).toMatchSnapshot();
});
test('is passing and is downloadable', () => {
const wrapper = createWrapper({
grade: { isPassing: true },
certificate: { isDownloadable: true },
});
expect(wrapper).toMatchSnapshot();
});
test('not passing and is downloadable', () => {
const wrapper = createWrapper({
grade: { isPassing: false },
certificate: { isDownloadable: true },
});
expect(wrapper).toMatchSnapshot();
});
test('not passing and audit', () => {
const wrapper = createWrapper({
enrollment: {
@@ -82,9 +99,7 @@ describe('CertificateBanner', () => {
});
test('not passing and has finished', () => {
const wrapper = createWrapper({
enrollment: {
hasFinished: true,
},
courseRun: { isArchived: true },
});
expect(wrapper).toMatchSnapshot();
});
@@ -92,17 +107,6 @@ describe('CertificateBanner', () => {
const wrapper = createWrapper({});
expect(wrapper).toMatchSnapshot();
});
test('is passing and is downloadable', () => {
const wrapper = createWrapper({
grade: {
isPassing: true,
},
certificate: {
isDownloadable: true,
},
});
expect(wrapper).toMatchSnapshot();
});
test('is passing and is earned but unavailable', () => {
const wrapper = createWrapper({
grade: {

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { Hyperlink } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks as appHooks } from 'data/redux';
import { utilHooks, reduxHooks } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
@@ -14,10 +14,10 @@ export const CourseBanner = ({ cardId }) => {
isAuditAccessExpired,
canUpgrade,
coursewareAccess = {},
} = appHooks.useCardEnrollmentData(cardId);
const courseRun = appHooks.useCardCourseRunData(cardId);
const course = appHooks.useCardCourseData(cardId);
const { formatMessage, formatDate } = useIntl();
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
const { formatMessage } = useIntl();
const formatDate = utilHooks.useFormatDate();
const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess;
@@ -36,11 +36,9 @@ export const CourseBanner = ({ cardId }) => {
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
{
<Hyperlink destination="">
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
}
<Hyperlink isInline destination="">
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
</Banner>
))}
@@ -48,7 +46,7 @@ export const CourseBanner = ({ cardId }) => {
<Banner>
{formatMessage(messages.upgradeDeadlinePassed)}
{' '}
<Hyperlink destination={course.website || ''}>
<Hyperlink isInline destination={courseRun.marketingUrl || ''}>
{formatMessage(messages.exploreCourseDetails)}
</Hyperlink>
</Banner>

View File

@@ -2,16 +2,18 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Hyperlink } from '@edx/paragon';
import { hooks as appHooks } from 'data/redux';
import { reduxHooks } from 'hooks';
import { formatMessage } from 'testUtils';
import { CourseBanner } from './CourseBanner';
import messages from './messages';
jest.mock('components/Banner', () => 'Banner');
jest.mock('data/redux', () => ({
hooks: {
useCardCourseData: jest.fn(),
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: () => date => date,
},
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
@@ -34,26 +36,19 @@ const enrollmentData = {
const courseRunData = {
isActive: false,
startDate: '11/11/3030',
};
const courseData = {
website: 'test-course-website',
marketingUrl: 'marketing-url',
};
const render = (overrides = {}) => {
const {
course = {},
courseRun = {},
enrollment = {},
} = overrides;
appHooks.useCardCourseData.mockReturnValueOnce({
...courseData,
...course,
});
appHooks.useCardCourseRunData.mockReturnValueOnce({
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
appHooks.useCardEnrollmentData.mockReturnValueOnce({
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
@@ -63,9 +58,8 @@ const render = (overrides = {}) => {
describe('CourseBanner', () => {
test('initializes data with course number from enrollment, course and course run data', () => {
render();
expect(appHooks.useCardCourseData).toHaveBeenCalledWith(cardId);
expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
});
test('no display if learner is verified', () => {
render({ enrollment: { isVerified: true } });
@@ -106,7 +100,7 @@ describe('CourseBanner', () => {
expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage);
const link = el.find(Hyperlink);
expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage);
expect(link.props().destination).toEqual(courseData.website);
expect(link.props().destination).toEqual(courseRunData.marketingUrl);
});
});
test('no display if audit access not expired and (course is not active or can upgrade)', () => {

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditBanner component render with error state snapshot 1`] = `
<Banner
variant="danger"
>
<p
className="credit-error-msg"
>
<format-message-function
message={
Object {
"defaultMessage": "An error occurred with this transaction. For help, contact {supportEmailLink}.",
"description": "",
"id": "learner-dash.courseCard.banners.credit.error",
}
}
values={
Object {
"supportEmailLink": <EmailLink
address="test-support-email"
/>,
}
}
/>
</p>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;
exports[`CreditBanner component render with no error state snapshot 1`] = `
<Banner>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;

View File

@@ -0,0 +1,41 @@
import { StrictDict } from 'utils';
import { reduxHooks } from 'hooks';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
import MustRequestContent from './views/MustRequestContent';
import PendingContent from './views/PendingContent';
import RejectedContent from './views/RejectedContent';
export const statusComponents = StrictDict({
pending: PendingContent,
approved: ApprovedContent,
rejected: RejectedContent,
});
export const useCreditBannerData = (cardId) => {
const credit = reduxHooks.useCardCreditData(cardId);
const { supportEmail } = reduxHooks.usePlatformSettingsData();
if (!credit.isEligible) { return null; }
const { error, purchased, requestStatus } = credit;
let ContentComponent = EligibleContent;
if (purchased) {
if (requestStatus == null) {
ContentComponent = MustRequestContent;
} else if (Object.keys(statusComponents).includes(requestStatus)) {
ContentComponent = statusComponents[requestStatus];
}
// Current behavior is to show Elligible State if unknown request status is returned
}
return {
ContentComponent,
error,
supportEmail,
};
};
export default {
useCreditBannerData,
};

View File

@@ -0,0 +1,90 @@
import { keyStore } from 'utils';
import { reduxHooks } from 'hooks';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
import MustRequestContent from './views/MustRequestContent';
import PendingContent from './views/PendingContent';
import RejectedContent from './views/RejectedContent';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
jest.mock('./views/ApprovedContent', () => 'ApprovedContent');
jest.mock('./views/EligibleContent', () => 'EligibleContent');
jest.mock('./views/MustRequestContent', () => 'MustRequestContent');
jest.mock('./views/PendingContent', () => 'PendingContent');
jest.mock('./views/RejectedContent', () => 'RejectedContent');
const cardId = 'test-card-id';
const statuses = keyStore(hooks.statusComponents);
const supportEmail = 'test-support-email';
let out;
const defaultProps = {
isEligible: true,
error: false,
isPurchased: false,
requestStatus: null,
};
const loadHook = (creditData = {}) => {
reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData });
out = hooks.useCreditBannerData(cardId);
};
describe('useCreditBannerData hook', () => {
beforeEach(() => {
reduxHooks.usePlatformSettingsData.mockReturnValue({ 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();
});
describe('non-credit-eligible learner', () => {
it('returns null if the learner is not credit eligible', () => {
loadHook({ isEligible: false });
expect(out).toEqual(null);
});
});
describe('credit-eligible learner', () => {
it('returns error object from credit', () => {
loadHook();
expect(out.error).toEqual(defaultProps.error);
loadHook({ error: true });
expect(out.error).toEqual(true);
});
describe('ContentComponent', () => {
it('returns EligibleContent if not purchased', () => {
loadHook();
expect(out.ContentComponent).toEqual(EligibleContent);
});
it('returns MustRequestContent if purchased but not requested', () => {
loadHook({ purchased: true });
expect(out.ContentComponent).toEqual(MustRequestContent);
});
it('returns PendingContent if purchased and request is pending', () => {
loadHook({ purchased: true, requestStatus: statuses.pending });
expect(out.ContentComponent).toEqual(PendingContent);
});
it('returns ApprovedContent if purchased and request is approved', () => {
loadHook({ purchased: true, requestStatus: statuses.approved });
expect(out.ContentComponent).toEqual(ApprovedContent);
});
it('returns RejectedContent if purchased and request is rejected', () => {
loadHook({ purchased: true, requestStatus: statuses.rejected });
expect(out.ContentComponent).toEqual(RejectedContent);
});
it('returns EligibleContent if purchased and request status is invalid', () => {
loadHook({ purchased: true, requestStatus: 'fake-status' });
expect(out.ContentComponent).toEqual(EligibleContent);
});
});
});
});

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import Banner from 'components/Banner';
import EmailLink from 'components/EmailLink';
import hooks from './hooks';
import messages from './messages';
export const CreditBanner = ({ cardId }) => {
const { formatMessage } = useIntl();
const hookData = hooks.useCreditBannerData(cardId);
if (hookData === null) {
return null;
}
const { ContentComponent, error, supportEmail } = hookData;
const supportEmailLink = (<EmailLink address={supportEmail} />);
return (
<Banner {...(error && { variant: 'danger' })}>
{error && (
<p className="credit-error-msg">
{formatMessage(messages.error, { supportEmailLink })}
</p>
)}
<ContentComponent cardId={cardId} />
</Banner>
);
};
CreditBanner.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CreditBanner;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import EmailLink from 'components/EmailLink';
import hooks from './hooks';
import messages from './messages';
import CreditBanner from '.';
jest.mock('components/Banner', () => 'Banner');
jest.mock('components/EmailLink', () => 'EmailLink');
jest.mock('./hooks', () => ({
useCreditBannerData: jest.fn(),
}));
let el;
const cardId = 'test-card-id';
const ContentComponent = () => 'ContentComponent';
const supportEmail = 'test-support-email';
describe('CreditBanner component', () => {
describe('behavior', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue(null);
el = shallow(<CreditBanner cardId={cardId} />);
});
it('initializes hooks with cardId', () => {
expect(hooks.useCreditBannerData).toHaveBeenCalledWith(cardId);
});
it('returns null if hookData is null', () => {
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('render', () => {
describe('with error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: true,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes danger variant to Banner parent', () => {
expect(el.find('Banner').props().variant).toEqual('danger');
});
it('includes credit-error-msg with support email link', () => {
expect(el.find('.credit-error-msg').containsMatchingElement(
formatMessage(messages.error, {
supportEmailLink: (<EmailLink address={supportEmail} />),
}),
)).toEqual(true);
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
describe('with no error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: false,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
});
});

View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
error: {
id: 'learner-dash.courseCard.banners.credit.error',
description: '',
defaultMessage: 'An error occurred with this transaction. For help, contact {supportEmailLink}.',
},
});
export default messages;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import messages from './messages';
export const ApprovedContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { formatMessage } = useIntl();
return (
<CreditContent
action={{ href, message: formatMessage(messages.viewCredit), disabled: isMasquerading }}
message={formatMessage(
messages.approved,
{
congratulations: <b>{formatMessage(messages.congratulations)}</b>,
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName,
},
)}
/>
);
};
ApprovedContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default ApprovedContent;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import ApprovedContent from './ApprovedContent';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
useMasqueradeData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
describe('ApprovedContent component', () => {
beforeEach(() => {
el = shallow(<ApprovedContent cardId={cardId} />);
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
let component;
beforeAll(() => {
component = el.find('CreditContent');
});
test('action.href from credit.providerStatusUrl', () => {
expect(component.props().action.href).toEqual(credit.providerStatusUrl);
});
test('action.message is formatted viewCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.viewCredit));
});
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
test('message is formatted approved message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.approved,
{
congratulations: (<b>{formatMessage(messages.congratulations)}</b>),
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } 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 onClick = track.credit.purchase(courseId);
const getCredit = formatMessage(messages.getCredit);
const message = providerName
? formatMessage(messages.eligibleFromProvider, { providerName })
: formatMessage(messages.eligible, { getCredit: (<b>{getCredit}</b>) });
return (
<CreditContent
action={{ onClick, message: getCredit }}
message={message}
/>
);
};
EligibleContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default EligibleContent;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { formatMessage } from 'testUtils';
import track from 'tracking';
import messages from './messages';
import EligibleContent from './EligibleContent';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('tracking', () => ({
credit: {
purchase: (...args) => ({ trackCredit: args }),
},
}));
let el;
let component;
const cardId = 'test-card-id';
const courseId = 'test-course-id';
const credit = {
providerName: 'test-credit-provider-name',
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useCardCourseRunData.mockReturnValue({ courseId });
const render = () => {
el = shallow(<EligibleContent cardId={cardId} />);
};
const loadComponent = () => {
component = el.find('CreditContent');
};
describe('EligibleContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
it('initializes course run data with cardId', () => {
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
loadComponent();
});
test('action.onClick sends credit purchase track event', () => {
expect(component.props().action.onClick).toEqual(
track.credit.purchase(courseId),
);
});
test('action.message is formatted getCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.getCredit));
});
test('message is formatted eligible message if no provider', () => {
reduxHooks.useCardCreditData.mockReturnValueOnce({});
render();
loadComponent();
expect(component.props().message).toEqual(formatMessage(
messages.eligible,
{ getCredit: (<b>{formatMessage(messages.getCredit)}</b>) },
));
});
test('message is formatted eligible message if provider', () => {
expect(component.props().message).toEqual(
formatMessage(messages.eligibleFromProvider, { providerName: credit.providerName }),
);
});
});
});
});

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import hooks from './hooks';
import messages from './messages';
export const MustRequestContent = ({ cardId }) => {
const { formatMessage } = useIntl();
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
return (
<CreditContent
action={{
message: formatMessage(messages.requestCredit),
onClick: createCreditRequest,
disabled: isMasquerading,
}}
message={formatMessage(messages.mustRequest, {
linkToProviderSite: (<ProviderLink cardId={cardId} />),
requestCredit: (<b>{formatMessage(messages.requestCredit)}</b>),
})}
requestData={requestData}
/>
);
};
MustRequestContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default MustRequestContent;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import hooks from './hooks';
import ProviderLink from './components/ProviderLink';
import MustRequestContent from './MustRequestContent';
jest.mock('./hooks', () => ({
useCreditRequestData: jest.fn(),
}));
jest.mock('hooks', () => ({
reduxHooks: { useMasqueradeData: jest.fn() },
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const requestData = { test: 'requestData' };
const createCreditRequest = jest.fn().mockName('createCreditRequest');
hooks.useCreditRequestData.mockReturnValue({
requestData,
createCreditRequest,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const render = () => {
el = shallow(<MustRequestContent cardId={cardId} />);
};
describe('MustRequestContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes credit request data with cardId', () => {
expect(hooks.useCreditRequestData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
component = el.find('CreditContent');
});
test('action.onClick calls createCreditRequest from useCreditRequestData hook', () => {
expect(component.props().action.onClick).toEqual(createCreditRequest);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(
formatMessage(messages.requestCredit),
);
});
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
test('message is formatted mustRequest message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.mustRequest, {
linkToProviderSite: <ProviderLink cardId={cardId} />,
requestCredit: <b>{formatMessage(messages.requestCredit)}</b>,
}),
);
});
test('requestData drawn from useCreditRequestData hook', () => {
expect(component.props().requestData).toEqual(requestData);
});
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import messages from './messages';
export const PendingContent = ({ cardId }) => {
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { formatMessage } = useIntl();
return (
<CreditContent
action={{
href,
message: formatMessage(messages.viewDetails),
disabled: isMasquerading,
}}
message={formatMessage(messages.received, { providerName })}
/>
);
};
PendingContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default PendingContent;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import PendingContent from './PendingContent';
jest.mock('hooks', () => ({
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() },
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const providerName = 'test-credit-provider-name';
const providerStatusUrl = 'test-credit-provider-status-url';
reduxHooks.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const render = () => {
el = shallow(<PendingContent cardId={cardId} />);
};
describe('PendingContent component', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes card credit data with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
component = el.find('CreditContent');
});
test('action.href will go to provider status site', () => {
expect(component.props().action.href).toEqual(providerStatusUrl);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(
formatMessage(messages.viewDetails),
);
});
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
test('message is formatted pending message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.received, { providerName }),
);
});
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } 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 { formatMessage } = useIntl();
return (
<CreditContent
message={formatMessage(messages.rejected, {
providerName: credit.providerName,
linkToProviderSite: (<ProviderLink cardId={cardId} />),
})}
/>
);
};
RejectedContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default RejectedContent;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import RejectedContent from './RejectedContent';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
let el;
let component;
const render = () => { el = shallow(<RejectedContent cardId={cardId} />); };
const loadComponent = () => { component = el.find('CreditContent'); };
describe('RejectedContent component', () => {
beforeEach(render);
describe('behavior', () => {
it('initializes credit data with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeAll(loadComponent);
test('no action is passed', () => {
expect(component.props().action).toEqual(undefined);
});
test('message is formatted rejected message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.rejected,
{
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});
});

View File

@@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button } from '@edx/paragon';
import CreditRequestForm from './CreditRequestForm';
export const CreditContent = ({ action, message, requestData }) => (
<>
<div className="message-copy credit-msg">
{message}
</div>
{action && (
<ActionRow className="mt-4">
<Button
as="a"
disabled={!!action.disabled}
// make sure href is not undefined. Paragon won't disable the button if href is undefined.
href={action.href || '#'}
rel="noopener"
target="_blank"
variant="outline-primary"
className="border-gray-400"
onClick={action.onClick}
>
{action.message}
</Button>
</ActionRow>
)}
<CreditRequestForm requestData={requestData} />
</>
);
CreditContent.defaultProps = {
action: null,
requestData: null,
};
CreditContent.propTypes = {
action: PropTypes.shape({
href: PropTypes.string,
onClick: PropTypes.func,
message: PropTypes.string,
disabled: PropTypes.bool,
}),
message: PropTypes.node.isRequired,
requestData: PropTypes.shape({
url: PropTypes.string,
parameters: PropTypes.objectOf(PropTypes.string),
}),
};
export default CreditContent;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { shallow } from 'enzyme';
import CreditContent from './CreditContent';
let el;
const action = {
href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'),
message: 'test-action-message',
disabled: false,
};
const message = 'test-message';
const requestData = { url: 'test-request-data-url', parameters: { key1: 'val1' } };
const props = { action, message, requestData };
describe('CreditContent component', () => {
describe('render', () => {
describe('with action', () => {
beforeEach(() => {
el = shallow(<CreditContent {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads href, onClick, and message into action row button', () => {
const buttonEl = el.find('ActionRow Button');
expect(buttonEl.props().href).toEqual(action.href);
expect(buttonEl.props().onClick).toEqual(action.onClick);
expect(buttonEl.props().disabled).toEqual(action.disabled);
expect(buttonEl.text()).toEqual(action.message);
});
it('loads message into credit-msg div', () => {
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
test('disables action button when action.disabled is true', () => {
el.setProps({ action: { ...action, disabled: true } });
expect(el.find('ActionRow Button').props().disabled).toEqual(true);
});
});
describe('without action', () => {
test('snapshot', () => {
el = shallow(<CreditContent {...{ message, requestData }} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads message into credit-msg div', () => {
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
});
});
});

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditRequestForm component render output valid requestData snapshot 1`] = `
<Form
accept-method="UTF-8"
action="test-request-data-url"
className="hidden"
method="POST"
>
<FormControl
as="textarea"
key="key1"
name="key1"
value="val1"
/>
<FormControl
as="textarea"
key="key2"
name="key2"
value="val2"
/>
<FormControl
as="textarea"
key="key3"
name="key3"
value="val3"
/>
<Button
type="submit"
/>
</Form>
`;

View File

@@ -0,0 +1,13 @@
import React from 'react';
export const useCreditRequestFormData = (requestData) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (requestData !== null) {
ref.current.click();
}
}, [requestData]);
return { ref };
};
export default useCreditRequestFormData;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import useCreditRequestFormData from './hooks';
const requestData = 'test-request-data';
let out;
const ref = {
current: { click: jest.fn() },
};
React.useRef.mockReturnValue(ref);
describe('useCreditRequestFormData hook', () => {
describe('behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes ref with null', () => {
useCreditRequestFormData(requestData);
expect(React.useRef).toHaveBeenCalledWith(null);
});
let cb;
let prereqs;
it('does not click current ref when request data changes and is null', () => {
useCreditRequestFormData(null);
([[cb, prereqs]] = React.useEffect.mock.calls);
expect(prereqs).toEqual([null]);
cb();
expect(ref.current.click).not.toHaveBeenCalled();
});
it('clicks current ref when request data changes and is not null', () => {
useCreditRequestFormData(requestData);
([[cb, prereqs]] = React.useEffect.mock.calls);
expect(prereqs).toEqual([requestData]);
cb();
expect(ref.current.click).toHaveBeenCalled();
});
});
describe('output', () => {
it('returns ref for submit button', () => {
out = useCreditRequestFormData(requestData);
expect(out.ref).toEqual(ref);
});
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Form, FormControl } from '@edx/paragon';
import useCreditRequestFormData from './hooks';
export const CreditRequestForm = ({ requestData }) => {
const { ref } = useCreditRequestFormData(requestData);
if (requestData === null) {
return null;
}
const { parameters, url } = requestData;
return (
<Form
accept-method="UTF-8"
action={url}
className="hidden"
method="POST"
>
{Object.keys(parameters).map((key) => (
<FormControl
as="textarea"
key={key}
name={key}
value={parameters[key]}
/>
))}
<Button type="submit" ref={ref} />
</Form>
);
};
CreditRequestForm.defaultProps = {
requestData: null,
};
CreditRequestForm.propTypes = {
requestData: PropTypes.shape({
parameters: PropTypes.objectOf(PropTypes.string),
url: PropTypes.string,
}),
};
export default CreditRequestForm;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { shallow } from 'enzyme';
import { keyStore } from 'utils';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),
}));
const ref = 'test-ref';
const requestData = {
url: 'test-request-data-url',
parameters: {
key1: 'val1',
key2: 'val2',
key3: 'val3',
},
};
const paramKeys = keyStore(requestData.parameters);
useCreditRequestFormData.mockReturnValue({ ref });
let el;
const shallowRender = (data) => { el = shallow(<CreditRequestForm requestData={data} />); };
describe('CreditRequestForm component', () => {
describe('behavior', () => {
it('initializes ref from hook with requestData', () => {
shallowRender(requestData);
expect(useCreditRequestFormData).toHaveBeenCalledWith(requestData);
});
});
describe('render output', () => {
describe('null requestData', () => {
it('returns null', () => {
shallowRender(null);
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('valid requestData', () => {
beforeEach(() => {
shallowRender(requestData);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads Form with requestData url', () => {
expect(el.find('Form').props().action).toEqual(requestData.url);
});
it('loads a textarea form control for each requestData parameter', () => {
const controls = el.find('FormControl');
expect(controls.at(0).props().name).toEqual(paramKeys.key1);
expect(controls.at(0).props().value).toEqual(requestData.parameters.key1);
expect(controls.at(1).props().name).toEqual(paramKeys.key2);
expect(controls.at(1).props().value).toEqual(requestData.parameters.key2);
expect(controls.at(2).props().name).toEqual(paramKeys.key3);
expect(controls.at(2).props().value).toEqual(requestData.parameters.key3);
});
});
});
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { render } from '@testing-library/react';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.unmock('@edx/paragon');
jest.unmock('react');
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),
}));
const ref = { current: { click: jest.fn() }, useRef: jest.fn() };
const requestData = {
url: 'test-request-data-url',
parameters: {
key1: 'val1',
key2: 'val2',
key3: 'val3',
},
};
useCreditRequestFormData.mockReturnValue({ ref });
let el;
describe('CreditRequestForm component ref behavior', () => {
it('loads submit button with ref from hook', () => {
el = render(<CreditRequestForm requestData={requestData} />);
const button = el.getByRole('button');
expect(ref.current).toEqual(button);
});
});

View File

@@ -0,0 +1,24 @@
/* eslint-disable max-len */
import React from 'react';
import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks';
import { Hyperlink } from '@edx/paragon';
export const ProviderLink = ({ cardId }) => {
const credit = reduxHooks.useCardCreditData(cardId);
return (
<Hyperlink
href={credit.providerStatusUrl}
rel="noopener"
target="_blank"
>
{credit.providerName}
</Hyperlink>
);
};
ProviderLink.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default ProviderLink;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import ProviderLink from './ProviderLink';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
},
}));
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
let el;
describe('ProviderLink component', () => {
beforeEach(() => {
reduxHooks.useCardCreditData.mockReturnValue(credit);
el = shallow(<ProviderLink cardId={cardId} />);
});
describe('behavior', () => {
it('initializes credit hook with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes credit.providerStatusUrl to the hyperlink href', () => {
expect(el.find('Hyperlink').props().href).toEqual(credit.providerStatusUrl);
});
it('passes providerName for the link message', () => {
expect(el.find('Hyperlink').text()).toEqual(credit.providerName);
});
});
});

View File

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditContent component render with action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<ActionRow
className="mt-4"
>
<Button
as="a"
className="border-gray-400"
disabled={false}
href="test-action-href"
onClick={[MockFunction test-action-onClick]}
rel="noopener"
target="_blank"
variant="outline-primary"
>
test-action-message
</Button>
</ActionRow>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;
exports[`CreditContent component render without action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProviderLink component render snapshot 1`] = `
<Hyperlink
href="test-credit-provider-status-url"
rel="noopener"
target="_blank"
>
test-credit-provider-name
</Hyperlink>
`;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { StrictDict } from 'utils';
import { apiHooks } from 'hooks';
import * as module from './hooks';
export const state = StrictDict({
creditRequestData: (val) => React.useState(val), // eslint-disable-line
});
export const useCreditRequestData = (cardId) => {
const [requestData, setRequestData] = module.state.creditRequestData(null);
const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId);
const createCreditRequest = (e) => {
e.preventDefault();
createCreditApiRequest()
.then((request) => {
setRequestData(request.data);
});
};
return { requestData, createCreditRequest };
};
export default {
useCreditRequestData,
};

View File

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

View File

@@ -0,0 +1,61 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
approved: {
id: 'learner-dash.courseCard.banners.credit.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: '',
defaultMessage: 'Congratulations!',
},
eligible: {
id: 'learner-dash.courseCard.banners.credit.eligible',
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: '',
defaultMessage: 'You are now eligible for credit from {providerName}. Congratulations!',
},
getCredit: {
id: 'learner-dash.courseCard.banners.credit.getCredit',
description: '',
defaultMessage: 'Get Credit',
},
mustRequest: {
id: 'learner-dash.courseCard.banners.credit.mustRequest',
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: '',
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: '',
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: '',
defaultMessage: 'Request Credit',
},
viewCredit: {
id: 'learner-dash.courseCard.banners.credit.viewCredit',
description: '',
defaultMessage: 'View Credit',
},
viewDetails: {
id: 'learner-dash.courseCard.banners.credit.viewDetails',
description: '',
defaultMessage: 'View Details',
},
});
export default messages;

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