Compare commits

...

90 Commits

Author SHA1 Message Date
Simon Chen
cdb0f48f08 feat!: upsell through a course cert preview pop up
Even if the learner is enrolled in the course as a audit learner, each course cards would show a link that, once clicked upon, will pop a modal with verified cert preview and upgrade button
2023-10-25 13:37:58 -04:00
Syed Ali Abbas Zaidi
3cdcc1fe61 chore: bump frontend-platform (#216) 2023-10-16 13:09:55 +05:00
Shahbaz Shabbir
82ff0d7ddb fix: get brand logo file path from env (#205)
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
2023-10-12 14:06:01 -04:00
Jenkins
1478956e34 chore(i18n): update translations 2023-10-08 12:47:23 -04:00
Syed Ali Abbas Zaidi
f049712430 feat: upgrade react router to v6 (#126)
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-10-03 15:10:27 -04:00
Juliana Kang
c977de2df9 feat: Update header user dropdown back to Order History (#208)
REV-3693
2023-10-02 15:20:43 -04:00
Juliana Kang
4b20c5bbdd Revert "Revert "feat: Update header user dropdown back to Order History"" 2023-09-18 14:14:21 -04:00
Juliana Kang
0c1fa2f030 Revert "feat: Update header user dropdown back to Order History" (#207)
REV-3693
2023-09-18 14:13:47 -04:00
Juliana Kang
91117cce6a Revert "feat: Update header user dropdown back to Order History" 2023-09-18 13:52:13 -04:00
Juliana Kang
e6dba8bdc2 feat: Update header user dropdown back to Order History (#206)
REV-3693
2023-09-18 10:34:49 -04:00
julianajlk
1d67ac5f24 test: Update snapshot for Order History 2023-09-15 12:27:05 -04:00
julianajlk
60d2f22c50 feat: Update header user dropdown back to Order History 2023-09-15 12:12:07 -04:00
Syed Sajjad Hussain Shah
5dc89d7404 fix: collapsed navbar icon fix (#204)
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-09-07 17:11:15 -04:00
Zainab Amir
0f24d3a52d fix: recommendations card design and painted door eventing (#203) 2023-09-06 19:26:21 +05:00
Syed Sajjad Hussain Shah
fc885d02dc fix: recommendations card design and painted door eventing 2023-09-06 17:22:27 +05:00
Syed Sajjad Hussain Shah
2e09d3632e feat: add painted door button for no recommendations (#198) 2023-09-01 10:50:56 +05:00
Syed Sajjad Hussain Shah
d8cb46da60 Merge branch 'master' into sajjad/VAN-1618 2023-09-01 10:32:16 +05:00
Mashal Malik
199d6e7c60 feat: update react & react-dom to v17 (#161) 2023-08-28 10:31:57 -04:00
mubbsharanwar
64563d58f9 fix: update dashboard recommendations url
Point cross product recommendations url from learner_recommendations to edx-recommendations plugin.

VAN-1596
2023-08-28 11:50:41 +05:00
Jenkins
1e9a0a87b6 chore(i18n): update translations 2023-08-27 12:47:12 -04:00
Syed Sajjad Hussain Shah
d42d0cdc59 feat: add painted door button for no recommendations
VAN-1618
2023-08-24 13:30:39 +05:00
Mubbshar Anwar
8fef92d94d fix: update dashboard recommendations url (#195)
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-08-24 11:12:39 +05:00
Ben Warzeski
b41eee47c9 Bw/recommendations painted door exp (#197)
Co-authored-by: Syed Sajjad  Hussain Shah <ssajjad@2u.com>
2023-08-23 11:53:19 -04:00
Ben Warzeski
909f3f1f47 Bw/fix email modal (#193) 2023-08-22 15:12:54 -04:00
Ben Warzeski
ce269e8c8f feat: Exec Education flag around course card menu and actions (#188)
Co-authored-by: jajjibhai008 <ejazofficial122@gmail.com>
2023-08-15 16:27:32 -04:00
Ejaz Ahmad
86a4573405 feat: show unenrollment button for executive education courses (#185) 2023-08-11 19:14:26 +05:00
jajjibhai008
be2258e409 feat: show unenrollment button for executive education courses 2023-08-11 12:46:28 +05:00
Ejaz Ahmad
be8cb85773 feat: frontend changes for executive education courses on B2C dashboard (#181) 2023-08-10 12:24:17 +05:00
jajjibhai008
a2c003e542 feat: frontend changes for executive education courses on B2C dashboard 2023-08-09 19:35:33 +05:00
Leangseu Kim
f1cfe3de68 chore: update email for ci workflow 2023-08-07 10:18:00 -04:00
Omar Al-Ithawi
d43c17a663 feat: include paragon in atlas pull (#179)
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-07-25 11:19:35 -04:00
Mashal Malik
c01042f1df chore: add paragon messages (#172) 2023-07-21 11:16:34 +05:00
Jody Bailey
ed2368222f fix: removed info Optimizely logs (#177) 2023-07-17 14:57:57 +02:00
Jody Bailey
103a67654c feat: Implemented product recommendations experiment (#174) 2023-07-11 16:45:14 +02:00
Jenkins
58c3720087 chore(i18n): update translations 2023-07-09 12:46:59 -04:00
leangseu-edx
4e47018a81 fix: stop user from unenroll after earned the certificate (#162) 2023-07-06 13:30:36 -04:00
Jody Bailey
e7d9255fe5 fix: initial optimizely and segment events (#170) 2023-06-30 12:40:59 +02:00
Jody Bailey
2c7e10ffc2 fix: adjusted footer widget to show placeholder for no recommendations (#169) 2023-06-29 08:58:17 +02:00
Jody Bailey
43aa5b088e fix: course list styling for hidden panel and new endpoint integration (#165)
Co-authored-by: Ben Warzeski <bwarzesk@gmail.com>
2023-06-27 16:28:07 +02:00
Jenkins
86b1f5df1a chore(i18n): update translations 2023-06-23 06:55:17 -04:00
Raza Dar
5c52b6861e feat: Changed Order History header menu item title to Orders & Subscriptions (#164) 2023-06-21 12:18:41 +05:00
Raza Dar
a358a6014f test: removed the old flag code 2023-06-21 00:03:07 +05:00
Raza Dar
6ebc94506b feat: update removed flag check for this change 2023-06-20 17:30:44 +05:00
Raza Dar
59ab63807f feat: update the flag SUBSCRIPTIONS_ORDERS_MENU_ITEM_ENABLED 2023-06-20 17:30:44 +05:00
Raza Dar
322a79afaa feat: update Order History changed to Orders & Subscriptions 2023-06-20 17:30:44 +05:00
Jenkins
c458f4942f chore(i18n): update translations 2023-06-18 12:46:55 -04:00
Jody Bailey
93a4dfb4d9 feat: Added cross product recommendations experiment initial render + query logic (#158) 2023-06-15 15:06:32 +02:00
Ghassan Maslamani
f92bd9c8f9 fix: force LMS url to reload when changed (#136) 2023-06-13 12:24:23 -03:00
leangseu-edx
5db95b0029 Revert "fix: stop user from unenroll after earned the certificate"
This reverts commit a479b7ead6.
2023-06-12 11:40:39 -04:00
Leangseu Kim
a479b7ead6 fix: stop user from unenroll after earned the certificate 2023-06-12 10:07:28 -04: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
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
Jenkins
5ca3036849 chore(i18n): update translations 2023-05-14 12:46:46 -04: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
244 changed files with 15658 additions and 35151 deletions

7
.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=''
@@ -35,3 +36,9 @@ ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES=''
CAREER_LINK_URL=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR='true'

View File

@@ -2,6 +2,7 @@ NODE_ENV='development'
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
@@ -42,3 +43,9 @@ 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=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -2,6 +2,7 @@ NODE_ENV='test'
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
@@ -41,3 +42,9 @@ 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=''
OPTIMIZELY_FULL_STACK_SDK_KEY='SDK Key'
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -5,6 +5,7 @@ const config = createConfig('eslint', {
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'import/no-import-module-exports': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
},
});

View File

@@ -11,17 +11,17 @@ on:
jobs:
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [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
@@ -49,9 +49,9 @@ jobs:
server_port: 465
username: ${{ secrets.EDX_SMTP_USERNAME }}
password: ${{ secrets.EDX_SMTP_PASSWORD }}
subject: Upgrade python requirements workflow failed in ${{github.repository}}
subject: CI workflow failed in ${{github.repository}}
to: masters-grades@edx.org
from: github-actions <github-actions@edx.org>
body: Upgrade python requirements workflow in ${{github.repository}} failed!
body: CI workflow in ${{github.repository}} failed!
For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}"

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

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.15

View File

@@ -1,15 +1,13 @@
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
@@ -49,15 +47,29 @@ 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 -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/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
$(intl_imports) paragon frontend-component-footer frontend-app-learner-dashboard
endif
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

40619
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,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",
@@ -27,14 +28,16 @@
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/browserslist-config": "^1.1.0",
"@edx/frontend-component-footer": "^11.4.1",
"@edx/frontend-enterprise-hotjar": "^1.2.0",
"@edx/frontend-platform": "^2.6.2",
"@edx/paragon": "20.19.0",
"@edx/frontend-component-footer": "^12.2.1",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^5.5.4",
"@edx/paragon": "^20.44.0",
"@edx/react-unit-test-utils": "^1.7.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.15",
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0",
@@ -51,17 +54,18 @@
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"jest": "^26.6.3",
"jest-when": "^3.6.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router-dom": "5.3.3",
"react-router-dom": "6.15.0",
"react-share": "^4.4.0",
"react-zendesk": "^0.1.13",
"redux": "4.1.1",
@@ -76,19 +80,20 @@
"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",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"axios-mock-adapter": "^1.20.0",
"enzyme-adapter-react-16": "^1.15.6",
"copy-webpack-plugin": "^11.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",
"react-test-renderer": "^17.0.2",
"redux-mock-store": "^1.5.4",
"semantic-release": "^17.4.5"
"semantic-release": "^20.1.3"
}
}

View File

@@ -5,6 +5,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (process.env.OPTIMIZELY_URL) { %>
<script
src="<%= process.env.OPTIMIZELY_URL %>"
></script>
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
></script>
<% } %>
</head>
<body>
<div id="root"></div>

2
public/robots.txt Normal file
View File

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

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -19,14 +18,16 @@ import {
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab';
import { ExperimentProvider } from 'ExperimentContext';
import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
import LearnerDashboardHeaderVariant from './containers/LearnerDashboardHeaderVariant';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import messages from './messages';
import './App.scss';
export const App = () => {
@@ -72,24 +73,30 @@ export const App = () => {
}
}, [authenticatedUser, loadData]);
return (
<Router>
<>
<Helmet>
<title>{formatMessage(messages.pageTitle)}</title>
</Helmet>
<div>
<LearnerDashboardHeaderVariant />
<main>
{hasNetworkFailure
? (
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (<Dashboard />)}
</main>
<AppWrapper>
<LearnerDashboardHeader />
<main>
{hasNetworkFailure
? (
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
)}
</main>
</AppWrapper>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab />
</div>
</Router>
</>
);
};

View File

@@ -1,26 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Helmet } from 'react-helmet';
import { ErrorPage } from '@edx/frontend-platform/react';
import { BrowserRouter as Router } from 'react-router-dom';
import { shallow } from '@edx/react-unit-test-utils';
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 LearnerDashboardHeaderVariant from 'containers/LearnerDashboardHeaderVariant';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import { ExperimentProvider } from 'ExperimentContext';
import { App } from './App';
import messages from './messages';
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeaderVariant', () => 'LearnerDashboardHeaderVariant');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'ExperimentProvider',
}));
jest.mock('containers/WidgetContainers/AppWrapper', () => 'AppWrapper');
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
@@ -49,18 +51,23 @@ describe('App router component', () => {
const { formatMessage } = useIntl();
describe('component', () => {
const runBasicTests = () => {
test('snapshot', () => { expect(el).toMatchSnapshot(); });
test('snapshot', () => { expect(el.snapshot).toMatchSnapshot(); });
it('displays title in helmet component', () => {
expect(el.find(Helmet).find('title').text()).toEqual(useIntl().formatMessage(messages.pageTitle));
const control = el.instance
.findByType(Helmet)[0]
.findByType('title')[0];
expect(control.children[0].el).toEqual(formatMessage(messages.pageTitle));
});
it('displays learner dashboard header', () => {
expect(el.find(LearnerDashboardHeaderVariant).length).toEqual(1);
});
it('wraps the page in a browser router', () => {
expect(el.find(Router)).toMatchObject(el);
expect(el.instance.findByType(LearnerDashboardHeader).length).toEqual(1);
});
test('Footer logo drawn from env variable', () => {
expect(el.find(Footer).props().logo).toEqual(logo);
expect(el.instance.findByType(Footer)[0].props.logo).toEqual(logo);
});
it('wraps the header and main components in an AppWrapper widget container', () => {
const container = el.instance.findByType(AppWrapper)[0];
expect(container.children[0].type).toEqual('LearnerDashboardHeader');
expect(container.children[1].type).toEqual('main');
});
};
describe('no network failure', () => {
@@ -70,9 +77,14 @@ describe('App router component', () => {
});
runBasicTests();
it('loads dashboard', () => {
expect(el.find('main')).toMatchObject(shallow(
<main><Dashboard /></main>,
));
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const expProvider = main.children[0];
expect(expProvider.type).toEqual('ExperimentProvider');
expect(expProvider.children.length).toEqual(1);
expect(
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
).toEqual(true);
});
});
describe('initialize failure', () => {
@@ -82,13 +94,14 @@ describe('App router component', () => {
});
runBasicTests();
it('loads error page', () => {
expect(el.find('main')).toEqual(shallow(
<main>
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
</main>,
));
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const alert = main.children[0];
expect(alert.type).toEqual('Alert');
expect(alert.children.length).toEqual(1);
const errorPage = alert.children[0];
expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
});
});
describe('refresh failure', () => {
@@ -98,13 +111,14 @@ describe('App router component', () => {
});
runBasicTests();
it('loads error page', () => {
expect(el.find('main')).toEqual(shallow(
<main>
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
</main>,
));
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const alert = main.children[0];
expect(alert.type).toEqual('Alert');
expect(alert.children.length).toEqual(1);
const errorPage = alert.children[0];
expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
});
});
});

64
src/ExperimentContext.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { StrictDict } from 'utils';
import api from 'widgets/ProductRecommendations/api';
import * as module from './ExperimentContext';
export const state = StrictDict({
experiment: (val) => React.useState(val), // eslint-disable-line
countryCode: (val) => React.useState(val), // eslint-disable-line
});
export const useCountryCode = (setCountryCode) => {
React.useEffect(() => {
api
.fetchRecommendationsContext()
.then((response) => {
setCountryCode(response.data.countryCode);
})
.catch(() => {
setCountryCode('');
});
/* eslint-disable */
}, []);
};
export const ExperimentContext = React.createContext();
export const ExperimentProvider = ({ children }) => {
const [countryCode, setCountryCode] = module.state.countryCode(null);
const [experiment, setExperiment] = module.state.experiment({
isExperimentActive: false,
inRecommendationsVariant: true,
});
module.useCountryCode(setCountryCode);
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;
const contextValue = React.useMemo(
() => ({
experiment,
countryCode,
setExperiment,
setCountryCode,
isMobile,
}),
[experiment, countryCode, setExperiment, setCountryCode, isMobile]
);
return (
<ExperimentContext.Provider value={contextValue}>
{children}
</ExperimentContext.Provider>
);
};
export const useExperimentContext = () => React.useContext(ExperimentContext);
ExperimentProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default { useCountryCode, useExperimentContext };

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { useWindowSize } from '@edx/paragon';
import api from 'widgets/ProductRecommendations/api';
import { MockUseState } from 'testUtils';
import * as experiment from 'ExperimentContext';
const state = new MockUseState(experiment);
jest.unmock('react');
jest.spyOn(React, 'useEffect').mockImplementation((cb, prereqs) => ({ useEffect: { cb, prereqs } }));
jest.mock('widgets/ProductRecommendations/api', () => ({
fetchRecommendationsContext: jest.fn(),
}));
describe('experiments context', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('useCountryCode', () => {
describe('behaviour', () => {
describe('useEffect call', () => {
let calls;
let cb;
const setCountryCode = jest.fn();
const successfulFetch = { data: { countryCode: 'ZA' } };
beforeEach(() => {
experiment.useCountryCode(setCountryCode);
({ calls } = React.useEffect.mock);
[[cb]] = calls;
});
it('calls useEffect once', () => {
expect(calls.length).toEqual(1);
});
describe('successfull fetch', () => {
it('sets the country code', async () => {
let resolveFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
resolveFn(successfulFetch);
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith(successfulFetch.data.countryCode);
});
});
});
describe('unsuccessfull fetch', () => {
it('sets the country code to an empty string', async () => {
let rejectFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve, reject) => {
rejectFn = reject;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith('');
});
});
});
});
});
});
describe('ExperimentProvider', () => {
const { ExperimentProvider } = experiment;
const TestComponent = () => {
const {
experiment: exp,
setExperiment,
countryCode,
setCountryCode,
isMobile,
} = experiment.useExperimentContext();
expect(exp.isExperimentActive).toBeFalsy();
expect(exp.inRecommendationsVariant).toBeTruthy();
expect(countryCode).toBeNull();
expect(isMobile).toBe(false);
expect(setExperiment).toBeDefined();
expect(setCountryCode).toBeDefined();
return (
<div />
);
};
it('allows access to child components with the context stateful values', () => {
const countryCodeSpy = jest.spyOn(experiment, 'useCountryCode').mockImplementationOnce(() => {});
useWindowSize.mockImplementationOnce(() => ({ width: 577, height: 943 }));
state.mock();
mount(
<ExperimentProvider>
<TestComponent />
</ExperimentProvider>,
);
expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
state.expectInitializedWith(state.keys.countryCode, null);
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
});
});
});

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component component initialize failure snapshot 1`] = `
<BrowserRouter>
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
@@ -11,26 +11,28 @@ exports[`App router component component initialize failure snapshot 1`] = `
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeaderVariant />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
</Fragment>
`;
exports[`App router component component no network failure snapshot 1`] = `
<BrowserRouter>
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
@@ -40,20 +42,24 @@ exports[`App router component component no network failure snapshot 1`] = `
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeaderVariant />
<main>
<Dashboard />
</main>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
</Fragment>
`;
exports[`App router component component refresh failure snapshot 1`] = `
<BrowserRouter>
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
@@ -63,20 +69,22 @@ exports[`App router component component refresh failure snapshot 1`] = `
</title>
</HelmetWrapper>
<div>
<LearnerDashboardHeaderVariant />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</BrowserRouter>
</Fragment>
`;

View File

@@ -1,36 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<IntlProvider
locale="en"
>
<ErrorPage
message="test-error-message"
/>
</IntlProvider>
<ErrorPage
message="test-error-message"
/>
`;
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",
}
>
<Switch>
<PageRoute
}
wrapWithRouter={true}
>
<NoticesWrapper>
<Routes>
<Route
element={
<PageWrap>
<App />
</PageWrap>
}
path="/"
>
<App />
</PageRoute>
<Redirect
to="/"
/>
</Switch>
</AppProvider>
</IntlProvider>
<Route
element={
<Navigate
replace={true}
to="/"
/>
}
path="*"
/>
</Routes>
</NoticesWrapper>
</AppProvider>
`;

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

@@ -20,6 +20,17 @@ exports[`ZendeskFab snapshot 1`] = `
},
},
"chat": Object {
"departments": Object {
"enabled": Array [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": Object {

View File

@@ -16,6 +16,9 @@ const ZendeskFab = () => {
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [

View File

@@ -1,6 +1,7 @@
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,
@@ -13,6 +14,9 @@ const configuration = {
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,
LOGO_URL: process.env.LOGO_URL,
};
const features = {};

View File

@@ -0,0 +1,29 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const useCertificatePreviewData = () => {
const { formatMessage } = useIntl();
const selectedCardId = reduxHooks.useCertificatePreviewData().cardId;
const { courseId } = reduxHooks.useCardCourseRunData(selectedCardId) || {};
const courseTitle = courseId;
const header = formatMessage(messages.previewTitle, { courseTitle });
const closePreviewModal = reduxHooks.useUpdateCertificatePreviewModalCallback(null);
const getCertificatePreviewUrl = () => `${getConfig().LMS_BASE_URL}/certificates/upsell/course/${courseId}`;
return {
showModal: selectedCardId != null,
header,
closePreviewModal,
getCertificatePreviewUrl,
};
};
export default useCertificatePreviewData;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ModalDialog,
} from '@edx/paragon';
import { UpgradeButton } from '../CourseCard/components/CourseCardActions/UpgradeButton';
import useCertificatePreviewData from './hooks';
export const CertificatePreviewModal = ({
cardId,
}) => {
const {
showModal,
header,
closePreviewModal,
getCertificatePreviewUrl,
} = useCertificatePreviewData();
return (
<ModalDialog
isOpen={showModal}
onClose={closePreviewModal}
hasCloseButton
isFullscreenOnMobile
size="lg"
className="p-4 px-4.5"
title={header}
>
<h3>{header}</h3>
<div>
<iframe
title={header}
src={getCertificatePreviewUrl()}
width={725}
height={400}
/>
</div>
<UpgradeButton
cardId={cardId}
/>
</ModalDialog>
);
};
CertificatePreviewModal.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CertificatePreviewModal;

View File

@@ -0,0 +1,12 @@
/* eslint-disable quotes */
import { StrictDict } from 'utils';
export const messages = StrictDict({
previewTitle: {
id: 'learner-dash.certificatePreview.title',
description: 'The title of the email settings modal',
defaultMessage: 'Your certificate preview for {courseTitle} ',
},
});
export default messages;

View File

@@ -5,22 +5,24 @@ import { useIntl } from '@edx/frontend-platform/i18n';
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 { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { hasAccess } = reduxHooks.useCardEnrollmentData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl,
homeUrl + execEdTrackingParam,
);
return (
<ActionButton
disabled={isMasquerading || !hasAccess}
disabled={disableBeginCourse}
as="a"
href="#"
onClick={handleClick}

View File

@@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
import BeginCourseButton from './BeginCourseButton';
jest.mock('tracking', () => ({
@@ -13,19 +14,22 @@ jest.mock('tracking', () => ({
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'home-url' })),
useCardEnrollmentData: jest.fn(() => ({ hasAccess: true })),
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
useCardCourseRunData: jest.fn(),
useCardExecEdTrackingParam: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
let wrapper;
const { homeUrl } = reduxHooks.useCardCourseRunData();
const homeUrl = 'home-url';
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
describe('BeginCourseButton', () => {
const props = {
@@ -34,37 +38,49 @@ describe('BeginCourseButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('snapshot', () => {
test('renders default button when learner has access to the course', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
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(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('initializes enrollment data with cardId', () => {
it('loads exec education path param', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
describe('disabled states', () => {
test('learner does not have access', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
it('loads disabled states for begin action from action hooks', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
describe('snapshot', () => {
describe('disabled', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
wrapper = shallow(<BeginCourseButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('masquerading', () => {
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
});
describe('enabled', () => {
beforeEach(() => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
homeUrl + execEdPath(props.cardId),
));
});
});
});

View File

@@ -5,22 +5,24 @@ import { useIntl } from '@edx/frontend-platform/i18n';
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 } = reduxHooks.useCardCourseRunData(cardId);
const { hasAccess, isAudit, isAuditAccessExpired } = reduxHooks.useCardEnrollmentData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { formatMessage } = useIntl();
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
resumeUrl,
resumeUrl + execEdTrackingParam,
);
return (
<ActionButton
disabled={isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)}
disabled={disableResumeCourse}
as="a"
href="#"
onClick={handleClick}

View File

@@ -3,80 +3,82 @@ import { shallow } from 'enzyme';
import { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
import ResumeButton from './ResumeButton';
jest.mock('tracking', () => ({
course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
},
}));
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('tracking', () => ({
course: {
enterCourseClicked: 'enterCourseClicked',
useCardCourseRunData: jest.fn(),
useCardExecEdTrackingParam: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
const { resumeUrl } = reduxHooks.useCardCourseRunData();
const resumeUrl = 'resume-url';
reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
let wrapper;
describe('ResumeButton', () => {
const props = {
cardId: 'cardId',
};
describe('snapshot', () => {
test('renders default button when learner has access to the course', () => {
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
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} />);
it('initializes course run data with cardId', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('initializes course enrollment data based on cardId', () => {
shallow(<ResumeButton {...props} />);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(props.cardId);
it('loads exec education path param', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
describe('disabled states', () => {
test('masquerading', () => {
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading: true });
const wrapper = shallow(<ResumeButton {...props} />);
it('loads disabled states for resume action from action hooks', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
describe('snapshot', () => {
describe('disabled', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValueOnce({ disableResumeCourse: true });
wrapper = shallow(<ResumeButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('learner does not have access', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
hasAccess: false,
isAudit: true,
isAuditAccessExpired: false,
});
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
describe('enabled', () => {
beforeEach(() => {
wrapper = shallow(<ResumeButton {...props} />);
});
test('audit access expired', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
hasAccess: true,
isAudit: true,
isAuditAccessExpired: true,
});
const wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
resumeUrl + execEdPath(props.cardId),
));
});
});
});

View File

@@ -4,18 +4,17 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const SelectSessionButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { hasAccess } = reduxHooks.useCardEnrollmentData(cardId);
const { canChange, hasSessions } = reduxHooks.useCardEntitlementData(cardId);
const { disableSelectSession } = useActionDisabledState(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
return (
<ActionButton
disabled={isMasquerading || !hasAccess || (!canChange || !hasSessions)}
disabled={disableSelectSession}
onClick={openSessionModal}
>
{formatMessage(messages.selectSession)}

View File

@@ -2,66 +2,34 @@ import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import useActionDisabledState from '../hooks';
import SelectSessionButton from './SelectSessionButton';
jest.mock('hooks', () => ({
reduxHooks: {
useCardEnrollmentData: jest.fn(() => ({ hasAccess: true })),
useCardEntitlementData: jest.fn(() => ({ canChange: true, hasSessions: true })),
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
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', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
it('renders disabled button if masquerading', () => {
reduxHooks.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(reduxHooks.useUpdateSelectSessionModalCallback().getMockName());
});
describe('disabled states', () => {
test('learner does not have access', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('learner cannot change sessions', () => {
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ canChange: false, hasSessions: true });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('entitlement does not have available sessions', () => {
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ canChange: true, hasSessions: false });
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('user is masquerading', () => {
reduxHooks.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

@@ -6,6 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
@@ -14,15 +15,14 @@ export const UpgradeButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { canUpgrade } = reduxHooks.useCardEnrollmentData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { disableUpgradeCourse } = useActionDisabledState(cardId);
const trackUpgradeClick = reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
cardId,
upgradeUrl,
);
const isEnabled = (!isMasquerading && canUpgrade);
const enabledProps = {
as: 'a',
href: upgradeUrl,
@@ -32,8 +32,8 @@ export const UpgradeButton = ({ cardId }) => {
<ActionButton
iconBefore={Locked}
variant="outline-primary"
disabled={!isEnabled}
{...isEnabled && enabledProps}
disabled={disableUpgradeCourse}
{...!disableUpgradeCourse && enabledProps}
>
{formatMessage(messages.upgrade)}
</ActionButton>

View File

@@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import useActionDisabledState from '../hooks';
import UpgradeButton from './UpgradeButton';
jest.mock('tracking', () => ({
@@ -13,15 +14,13 @@ jest.mock('tracking', () => ({
jest.mock('hooks', () => ({
reduxHooks: {
useMasqueradeData: jest.fn(() => ({ isMasquerading: false })),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(() => ({ canUpgrade: true })),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableUpgradeCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
describe('UpgradeButton', () => {
@@ -42,13 +41,7 @@ describe('UpgradeButton', () => {
));
});
test('cannot upgrade', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ canUpgrade: false });
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
test('masquerading', () => {
reduxHooks.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

@@ -5,23 +5,23 @@ import { useIntl } from '@edx/frontend-platform/i18n';
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 { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { hasAccess, isAudit, isAuditAccessExpired } = reduxHooks.useCardEnrollmentData(cardId);
const { disableViewCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl,
);
// disabled on no access or (is audit track but audit access was expired)
const disabledViewCourseButton = !hasAccess || (isAudit && isAuditAccessExpired);
return (
<ActionButton
disabled={disabledViewCourseButton}
disabled={disableViewCourse}
as="a"
href="#"
onClick={handleClick}

View File

@@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import track from 'tracking';
import { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ViewCourseButton from './ViewCourseButton';
jest.mock('tracking', () => ({
@@ -13,73 +14,33 @@ jest.mock('tracking', () => ({
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
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');
let wrapper;
const defaultProps = { cardId: 'cardId' };
const homeUrl = 'homeUrl';
const createWrapper = ({
hasAccess = false,
isAudit = false,
isAuditAccessExpired = false,
isEntitlement = false,
isExpired = false,
propsOveride = {},
}) => {
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasAccess, isAudit, isAuditAccessExpired });
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isExpired });
return shallow(<ViewCourseButton {...defaultProps} {...propsOveride} />);
};
describe('ViewCourseButton', () => {
describe('learner has access to course', () => {
beforeEach(() => {
wrapper = createWrapper({ hasAccess: true });
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
test('links to home URL', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
defaultProps.cardId,
homeUrl,
));
});
test('link is enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
test('link is disabled when audit access is expired', () => {
wrapper = createWrapper({ hasAccess: true, isAudit: true, isAuditAccessExpired: true });
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
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('learner does not have access to course', () => {
beforeEach(() => {
wrapper = createWrapper({ hasAccess: false });
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
test('links to home URL', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
defaultProps.cardId,
homeUrl,
));
});
test('link is disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
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,6 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BeginCourseButton snapshot renders default button when learner has access to the course 1`] = `
exports[`BeginCourseButton snapshot disabled snapshot 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}
>
Begin Course
</ActionButton>
`;
exports[`BeginCourseButton snapshot enabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
@@ -10,7 +29,7 @@ exports[`BeginCourseButton snapshot renders default button when learner has acce
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-url",
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}

View File

@@ -1,11 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResumeButton snapshot renders default button when learner has access to the course 1`] = `
exports[`ResumeButton snapshot disabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
disabled={true}
href="#"
onClick={[MockFunction useTrackCourseEvent('enterCourseClicked', 'cardId', 'resumeUrl')]}
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
>
Resume
</ActionButton>
`;
exports[`ResumeButton snapshot enabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
>
Resume
</ActionButton>

View File

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

View File

@@ -30,13 +30,3 @@ exports[`UpgradeButton snapshot cannot upgrade 1`] = `
Upgrade
</ActionButton>
`;
exports[`UpgradeButton snapshot masquerading 1`] = `
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
variant="outline-primary"
>
Upgrade
</ActionButton>
`;

View File

@@ -1,25 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ViewCourseButton learner does not have access to course snapshot 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</ActionButton>
`;
exports[`ViewCourseButton learner has access to course snapshot 1`] = `
exports[`ViewCourseButton learner can view course 1`] = `
<ActionButton
as="a"
disabled={false}
@@ -37,3 +18,22 @@ exports[`ViewCourseButton learner has access to course snapshot 1`] = `
View Course
</ActionButton>
`;
exports[`ViewCourseButton learner cannot view course 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</ActionButton>
`;

View File

@@ -1,54 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardActions snapshot show begin course button when verified and not entitlement and has started 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<BeginCourseButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show resume button when verified and not entitlement and has started 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<ResumeButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show select session button when not verified and entitlement 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<SelectSessionButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show upgrade button when not verified and not entitlement 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<UpgradeButton
cardId="cardId"
/>
<BeginCourseButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show view course button when not verified and entitlement and fulfilled 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<ViewCourseButton
cardId="cardId"
/>
</ActionRow>
`;

View File

@@ -13,21 +13,27 @@ import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => {
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const { isVerified, hasStarted } = reduxHooks.useCardEnrollmentData(cardId);
const {
isVerified,
hasStarted,
isExecEd2UCourse,
} = reduxHooks.useCardEnrollmentData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
let PrimaryButton;
if (isEntitlement) {
PrimaryButton = isFulfilled ? ViewCourseButton : SelectSessionButton;
} else if (isArchived) {
PrimaryButton = ViewCourseButton;
} else {
PrimaryButton = hasStarted ? ResumeButton : BeginCourseButton;
}
return (
<ActionRow data-test-id="CourseCardActions">
{!(isEntitlement || isVerified) && <UpgradeButton cardId={cardId} />}
<PrimaryButton cardId={cardId} />
{!(isEntitlement || isVerified || isExecEd2UCourse) && <UpgradeButton cardId={cardId} />}
{isEntitlement && (isFulfilled
? <ViewCourseButton cardId={cardId} />
: <SelectSessionButton cardId={cardId} />
)}
{(isArchived && !isEntitlement) && (
<ViewCourseButton cardId={cardId} />
)}
{!(isArchived || isEntitlement) && (hasStarted
? <ResumeButton cardId={cardId} />
: <BeginCourseButton cardId={cardId} />
)}
</ActionRow>
);
};

View File

@@ -1,7 +1,13 @@
import { shallow } from 'enzyme';
import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
import BeginCourseButton from './BeginCourseButton';
import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton';
import CourseCardActions from '.';
jest.mock('hooks', () => ({
@@ -9,6 +15,7 @@ jest.mock('hooks', () => ({
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useMasqueradeData: jest.fn(),
},
}));
@@ -18,87 +25,92 @@ jest.mock('./ViewCourseButton', () => 'ViewCourseButton');
jest.mock('./BeginCourseButton', () => 'BeginCourseButton');
jest.mock('./ResumeButton', () => 'ResumeButton');
const cardId = 'test-card-id';
const props = { cardId };
let el;
describe('CourseCardActions', () => {
const props = {
cardId: 'cardId',
};
const createWrapper = ({
isEntitlement, isFulfilled, isArchived, isVerified, hasStarted,
}) => {
const mockHooks = ({
isEntitlement = false,
isExecEd2UCourse = false,
isFulfilled = false,
isArchived = false,
isVerified = false,
hasStarted = false,
isMasquerading = false,
} = {}) => {
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isVerified, hasStarted });
return shallow(<CourseCardActions {...props} />);
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted });
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
};
describe('snapshot', () => {
test('show upgrade button when not verified and not entitlement', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
});
test('show select session button when not verified and entitlement', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
});
test('show begin course button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
});
test('show resume button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: true,
});
expect(wrapper).toMatchSnapshot();
});
test('show view course button when not verified and entitlement and fulfilled', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: true, isArchived: false, isVerified: false, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
const render = () => {
el = shallow(<CourseCardActions {...props} />);
};
describe('behavior', () => {
it('initializes redux hooks', () => {
mockHooks();
render();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
it('show upgrade button when not verified and not entitlement', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false,
describe('output', () => {
describe('Exec Ed course', () => {
it('does not render upgrade button', () => {
mockHooks({ isExecEd2UCourse: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
expect(wrapper.find('UpgradeButton')).toHaveLength(1);
});
it('show select session button when not verified and entitlement', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false,
describe('entitlement course', () => {
it('does not render upgrade button', () => {
mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
it('renders ViewCourseButton if fulfilled', () => {
mockHooks({ isEntitlement: true, isFulfilled: true });
render();
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
it('renders SelectSessionButton if not fulfilled', () => {
mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(SelectSessionButton)[0].props.cardId).toEqual(cardId);
});
expect(wrapper.find('SelectSessionButton')).toHaveLength(1);
});
it('show begin course button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: false,
describe('verified course', () => {
it('does not render upgrade button', () => {
mockHooks({ isVerified: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
expect(wrapper.find('BeginCourseButton')).toHaveLength(1);
});
it('show resume button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: true,
describe('not entielement, verified, or exec ed', () => {
it('renders UpgradeButton and ViewCourseButton for archived courses', () => {
mockHooks({ isArchived: true });
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
expect(wrapper.find('ResumeButton')).toHaveLength(1);
});
it('show view course button when not verified and entitlement and fulfilled', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: true, isArchived: false, isVerified: false, hasStarted: false,
describe('unstarted courses', () => {
it('renders UpgradeButton and BeginCourseButton', () => {
mockHooks();
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(BeginCourseButton)[0].props.cardId).toEqual(cardId);
});
});
expect(wrapper.find('ViewCourseButton')).toHaveLength(1);
});
it('show view course button when not verified and entitlement and fulfilled and archived', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: true, isArchived: true, isVerified: false, hasStarted: false,
describe('active courses (started, and not archived)', () => {
it('renders UpgradeButton and ResumeButton', () => {
mockHooks({ hasStarted: true });
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ResumeButton)[0].props.cardId).toEqual(cardId);
});
});
expect(wrapper.find('ViewCourseButton')).toHaveLength(1);
});
});
});

View File

@@ -40,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 (
@@ -63,17 +78,6 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (certificate.isDownloadable) {
return (
<Banner variant="success" icon={CheckCircle}>
{formatMessage(messages.certReady)}
{' '}
<Hyperlink isInline destination={certificate.certPreviewUrl}>
{formatMessage(messages.viewCertificate)}
</Hyperlink>
</Banner>
);
}
if (certificate.isEarnedButUnavailable) {
return (
<Banner>

View File

@@ -54,6 +54,7 @@ describe('CertificateBanner', () => {
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun });
return shallow(<CertificateBanner {...props} />);
};
/** TODO: Update tests to validate snapshots **/
describe('snapshot', () => {
test('is restricted', () => {
const wrapper = createWrapper({
@@ -74,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: {
@@ -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

@@ -36,11 +36,9 @@ export const CourseBanner = ({ cardId }) => {
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
{
<Hyperlink isInline destination="">
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
}
<Hyperlink isInline destination="">
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
</Banner>
))}

View File

@@ -11,10 +11,11 @@ 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) }}
action={{ href, message: formatMessage(messages.viewCredit), disabled: isMasquerading }}
message={formatMessage(
messages.approved,
{

View File

@@ -10,6 +10,7 @@ import ApprovedContent from './ApprovedContent';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCreditData: jest.fn(),
useMasqueradeData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
@@ -22,6 +23,7 @@ const credit = {
providerName: 'test-credit-provider-name',
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
describe('ApprovedContent component', () => {
beforeEach(() => {
@@ -44,6 +46,9 @@ describe('ApprovedContent component', () => {
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,

View File

@@ -3,6 +3,7 @@ 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';
@@ -12,11 +13,13 @@ 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} />),

View File

@@ -3,6 +3,7 @@ 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';
@@ -11,6 +12,9 @@ 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');
@@ -20,7 +24,11 @@ let component;
const cardId = 'test-card-id';
const requestData = { test: 'requestData' };
const createCreditRequest = jest.fn().mockName('createCreditRequest');
hooks.useCreditRequestData.mockReturnValue({ requestData, createCreditRequest });
hooks.useCreditRequestData.mockReturnValue({
requestData,
createCreditRequest,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const render = () => {
el = shallow(<MustRequestContent cardId={cardId} />);
@@ -43,13 +51,18 @@ describe('MustRequestContent component', () => {
expect(component.props().action.onClick).toEqual(createCreditRequest);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.requestCredit));
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>),
linkToProviderSite: <ProviderLink cardId={cardId} />,
requestCredit: <b>{formatMessage(messages.requestCredit)}</b>,
}),
);
});

View File

@@ -9,12 +9,14 @@ 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 })}
/>

View File

@@ -7,7 +7,9 @@ import { reduxHooks } from 'hooks';
import messages from './messages';
import PendingContent from './PendingContent';
jest.mock('hooks', () => ({ reduxHooks: { useCardCreditData: jest.fn() } }));
jest.mock('hooks', () => ({
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() },
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
@@ -17,7 +19,11 @@ 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.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const render = () => {
el = shallow(<PendingContent cardId={cardId} />);
@@ -40,7 +46,12 @@ describe('PendingContent component', () => {
expect(component.props().action.href).toEqual(providerStatusUrl);
});
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.viewDetails));
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(

View File

@@ -13,7 +13,9 @@ export const CreditContent = ({ action, message, requestData }) => (
<ActionRow className="mt-4">
<Button
as="a"
href={action.href}
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"
@@ -36,6 +38,7 @@ CreditContent.propTypes = {
href: PropTypes.string,
onClick: PropTypes.func,
message: PropTypes.string,
disabled: PropTypes.bool,
}),
message: PropTypes.node.isRequired,
requestData: PropTypes.shape({

View File

@@ -8,6 +8,7 @@ const action = {
href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'),
message: 'test-action-message',
disabled: false,
};
const message = 'test-message';
@@ -27,6 +28,7 @@ describe('CreditContent component', () => {
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', () => {
@@ -35,6 +37,10 @@ describe('CreditContent component', () => {
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', () => {

View File

@@ -13,6 +13,7 @@ exports[`CreditContent component render with action snapshot 1`] = `
<Button
as="a"
className="border-gray-400"
disabled={false}
href="test-action-href"
onClick={[MockFunction test-action-onClick]}
rel="noopener"

View File

@@ -14,7 +14,10 @@ export const useCreditRequestData = (cardId) => {
const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId);
const createCreditRequest = (e) => {
e.preventDefault();
createCreditApiRequest().then(setRequestData);
createCreditApiRequest()
.then((request) => {
setRequestData(request.data);
});
};
return { requestData, createCreditRequest };
};

View File

@@ -11,7 +11,7 @@ jest.mock('hooks', () => ({
const state = new MockUseState(hooks);
const cardId = 'test-card-id';
const requestData = { test: 'request data' };
const requestData = { data: 'request data' };
const creditRequest = jest.fn().mockReturnValue(Promise.resolve(requestData));
apiHooks.useCreateCreditRequest.mockReturnValue(creditRequest);
const event = { preventDefault: jest.fn() };
@@ -48,7 +48,7 @@ describe('Credit Banner view hooks', () => {
it('calls api.createCreditRequest and sets requestData with the response', async () => {
await out.createCreditRequest(event);
expect(creditRequest).toHaveBeenCalledWith();
expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData);
expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData.data);
});
});
});

View File

@@ -6,12 +6,6 @@ exports[`CertificateBanner snapshot is passing and is downloadable 1`] = `
variant="success"
>
Congratulations. Your certificate is ready.
<Hyperlink
isInline={true}
>
View Certificate.
</Hyperlink>
</Banner>
`;
@@ -113,6 +107,15 @@ exports[`CertificateBanner snapshot not passing and has finished 1`] = `
</Banner>
`;
exports[`CertificateBanner snapshot not passing and is downloadable 1`] = `
<Banner
icon={[MockFunction icons.CheckCircle]}
variant="success"
>
Congratulations. Your certificate is ready.
</Banner>
`;
exports[`CertificateBanner snapshot not passing and not audit and not finished 1`] = `
<Banner
variant="warning"

View File

@@ -47,6 +47,7 @@ export const useCardDetailsData = ({ cardId }) => {
} = reduxHooks.useCardEntitlementData(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
const openCertificatePreview = reduxHooks.useUpdateCertificatePreviewModalCallback(cardId);
return {
providerName: providerName || formatMessage(messages.unknownProviderName),
@@ -57,6 +58,7 @@ export const useCardDetailsData = ({ cardId }) => {
openSessionModal,
courseNumber,
changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton),
openCertificatePreview,
};
};

View File

@@ -41,7 +41,7 @@ describe('CourseCardDetails hooks', () => {
};
const entitlementData = {
isEntitlement: false,
canViewCourse: false,
disableViewCourse: false,
isFulfilled: false,
isExpired: false,
canChange: false,

View File

@@ -16,23 +16,35 @@ export const CourseCardDetails = ({ cardId }) => {
openSessionModal,
courseNumber,
changeOrLeaveSessionMessage,
openCertificatePreview,
} = useCardDetailsData({ cardId });
return (
<span className="small" data-testid="CourseCardDetails">
{providerName} {courseNumber}
{!(isEntitlement && !isFulfilled) && accessMessage && (
`${accessMessage}`
)}
{isEntitlement && isFulfilled && canChange ? (
<>
{' • '}
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}>
{changeOrLeaveSessionMessage}
</Button>
</>
) : null}
</span>
<div>
<span className="small" data-testid="CourseCardDetails">
{providerName} {courseNumber}
{!(isEntitlement && !isFulfilled) && accessMessage && (
`${accessMessage}`
)}
{isEntitlement && isFulfilled && canChange ? (
<>
{' • '}
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}>
{changeOrLeaveSessionMessage}
</Button>
</>
) : null}
</span>
<Button
variant="link"
size="inline"
className="float-right"
data-testid="certificate-preview"
onClick={openCertificatePreview}
>
Preview Your Certificate
</Button>
</div>
);
};

View File

@@ -6,8 +6,8 @@ import { Badge } from '@edx/paragon';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import verifiedRibbon from 'assets/verified-ribbon.png';
import useActionDisabledState from './hooks';
import messages from '../messages';
@@ -18,13 +18,13 @@ export const CourseCardImage = ({ cardId, orientation }) => {
const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { isVerified } = reduxHooks.useCardEnrollmentData(cardId);
const { isEntitlement } = reduxHooks.useCardEntitlementData(cardId);
const { disableCourseTitle } = useActionDisabledState(cardId);
const handleImageClicked = reduxHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl);
const wrapperClassName = `pgn__card-wrapper-image-cap overflow-visible ${orientation}`;
const image = (
<>
<img
className="pgn__card-image-cap"
className="pgn__card-image-cap show"
src={bannerImgSrc}
alt={formatMessage(messages.bannerAlt)}
/>
@@ -43,7 +43,7 @@ export const CourseCardImage = ({ cardId, orientation }) => {
}
</>
);
return isEntitlement
return disableCourseTitle
? (<div className={wrapperClassName}>{image}</div>)
: (
<a

View File

@@ -0,0 +1,66 @@
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from './hooks';
import CourseCardImage from './CourseCardImage';
const homeUrl = 'home-url';
jest.mock('tracking', () => ({
course: {
courseImageClicked: jest.fn().mockName('segment.courseImageClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(() => ({ bannerImgSrc: 'banner-img-src' })),
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
useCardEnrollmentData: jest.fn(() => ({ isVerified: true })),
useTrackCourseEvent: jest.fn((eventName, cardId, upgradeUrl) => ({
trackCourseEvent: { eventName, cardId, upgradeUrl },
})),
},
}));
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
describe('CourseCardImage', () => {
const props = {
cardId: 'cardId',
orientation: 'orientation',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('snapshot', () => {
test('renders clickable link course Image', () => {
const wrapper = shallow(<CourseCardImage {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.type()).toBe('a');
expect(wrapper.prop('onClick')).toEqual(
reduxHooks.useTrackCourseEvent(
track.course.courseImageClicked,
props.cardId,
homeUrl,
),
);
});
test('renders disabled link', () => {
useActionDisabledState.mockReturnValueOnce({ disableCourseTitle: true });
const wrapper = shallow(<CourseCardImage {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.type()).toBe('div');
});
});
describe('behavior', () => {
it('initializes', () => {
shallow(<CourseCardImage {...props} />);
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(
props.cardId,
);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
});

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as ReactShare from 'react-share';
import { StrictDict } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const testIds = StrictDict({
emailSettingsModalToggle: 'emailSettingsModalToggle',
});
export const SocialShareMenu = ({ cardId, emailSettings }) => {
const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { isEmailEnabled, isExecEd2UCourse } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const handleTwitterShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'twitter');
const handleFacebookShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'facebook');
if (isExecEd2UCourse) {
return null;
}
return (
<>
{isEmailEnabled && (
<Dropdown.Item
disabled={isMasquerading}
onClick={emailSettings.show}
data-testid={testIds.emailSettingsModalToggle}
>
{formatMessage(messages.emailSettings)}
</Dropdown.Item>
)}
{facebook.isEnabled && (
<ReactShare.FacebookShareButton
url={facebook.shareUrl}
onClick={handleFacebookShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: facebook.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton>
)}
{twitter.isEnabled && (
<ReactShare.TwitterShareButton
url={twitter.shareUrl}
onClick={handleTwitterShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: twitter.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton>
)}
</>
);
};
SocialShareMenu.propTypes = {
cardId: PropTypes.string.isRequired,
emailSettings: PropTypes.shape({
show: PropTypes.func,
}).isRequired,
};
export default SocialShareMenu;

View File

@@ -0,0 +1,235 @@
import { when } from 'jest-when';
import * as ReactShare from 'react-share';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import { useEmailSettings } from './hooks';
import SocialShareMenu, { testIds } from './SocialShareMenu';
import messages from './messages';
jest.mock('react-share', () => ({
FacebookShareButton: () => 'FacebookShareButton',
TwitterShareButton: () => 'TwitterShareButton',
}));
jest.mock('tracking', () => ({
socialShare: 'test-social-share-key',
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: jest.fn().mockReturnValue({
formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage,
}),
}));
jest.mock('hooks', () => ({
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
},
}));
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
}));
const props = {
cardId: 'test-card-id',
emailSettings: { show: jest.fn() },
};
const mockHook = (fn, returnValue, options = {}) => {
if (options.isCardHook) {
when(fn).calledWith(props.cardId).mockReturnValueOnce(returnValue);
} else {
when(fn).calledWith().mockReturnValueOnce(returnValue);
}
};
const courseName = 'test-course-name';
const socialShare = {
facebook: {
isEnabled: true,
shareUrl: 'facebook-share-url',
socialBrand: 'facebook-social-brand',
},
twitter: {
isEnabled: true,
shareUrl: 'twitter-share-url',
socialBrand: 'twitter-social-brand',
},
};
const mockHooks = (returnVals = {}) => {
mockHook(
reduxHooks.useCardEnrollmentData,
{
isEmailEnabled: !!returnVals.isEmailEnabled,
isExecEd2UCourse: !!returnVals.isExecEd2UCourse,
},
{ isCardHook: true },
);
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
reduxHooks.useCardSocialSettingsData,
{
facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled },
},
{ isCardHook: true },
);
};
let el;
const render = () => {
el = shallow(<SocialShareMenu {...props} />);
};
describe('SocialShareMenu', () => {
describe('behavior', () => {
beforeEach(() => {
mockHooks();
render();
});
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalledWith();
});
it('initializes local hooks', () => {
when(useEmailSettings).expectCalledWith();
});
it('initializes redux hook data ', () => {
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId);
when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId);
when(reduxHooks.useMasqueradeData).expectCalledWith();
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
});
});
describe('render', () => {
it('renders null if exec ed course', () => {
mockHooks({ isExecEd2UCourse: true });
render();
expect(el.isEmptyRender()).toEqual(true);
});
const testEmailSettingsDropdown = (isMasquerading = false) => {
describe('email settings dropdown', () => {
const loadToggle = () => el.instance.findByTestId(testIds.emailSettingsModalToggle)[0];
it('renders', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(1);
});
if (isMasquerading) {
it('is disabled', () => {
expect(loadToggle().props.disabled).toEqual(true);
});
} else {
it('is enabled', () => {
expect(loadToggle().props.disabled).toEqual(false);
});
}
test('show email settings modal on click', () => {
expect(loadToggle().props.onClick).toEqual(props.emailSettings.show);
});
});
};
const testFacebookShareButton = () => {
test('renders facebook share button with courseName and brand', () => {
const button = el.instance.findByType(ReactShare.FacebookShareButton)[0];
expect(button.props.url).toEqual(socialShare.facebook.shareUrl);
expect(button.props.onClick).toEqual(
reduxHooks.useTrackCourseEvent(track.socialShare, props.cardId, 'facebook'),
);
expect(button.props.title).toEqual(formatMessage(messages.shareQuote, {
courseName,
socialBrand: socialShare.facebook.socialBrand,
}));
});
};
const testTwitterShareButton = () => {
test('renders twitter share button with courseName and brand', () => {
const button = el.instance.findByType(ReactShare.TwitterShareButton)[0];
expect(button.props.url).toEqual(socialShare.twitter.shareUrl);
expect(button.props.onClick).toEqual(
reduxHooks.useTrackCourseEvent(track.socialShare, props.cardId, 'twitter'),
);
expect(button.props.title).toEqual(formatMessage(messages.shareQuote, {
courseName,
socialBrand: socialShare.twitter.socialBrand,
}));
});
};
describe('all enabled', () => {
beforeEach(() => {
mockHooks({
facebook: { isEnabled: true },
twitter: { isEnabled: true },
isEmailEnabled: true,
});
render();
});
describe('email settings dropdown', () => {
const loadToggle = () => el.instance.findByTestId(testIds.emailSettingsModalToggle)[0];
it('renders', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(1);
});
it('is enabled', () => {
expect(loadToggle().props.disabled).toEqual(false);
});
test('show email settings modal on click', () => {
expect(loadToggle().props.onClick).toEqual(props.emailSettings.show);
});
});
testEmailSettingsDropdown();
testFacebookShareButton();
testTwitterShareButton();
});
describe('only email enabled', () => {
beforeEach(() => {
mockHooks({ isEmailEnabled: true });
render();
});
testEmailSettingsDropdown();
it('does not render facebook or twitter controls', () => {
expect(el.instance.findByType(ReactShare.FacebookShareButton).length).toEqual(0);
expect(el.instance.findByType(ReactShare.TwitterShareButton).length).toEqual(0);
});
describe('masquerading', () => {
beforeEach(() => {
mockHooks({ isEmailEnabled: true, isMasquerading: true });
render();
});
testEmailSettingsDropdown(true);
});
});
describe('only facebook enabled', () => {
beforeEach(() => {
mockHooks({ facebook: { isEnabled: true } });
render();
});
testFacebookShareButton();
it('does not render email or twitter controls', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(0);
expect(el.instance.findByType(ReactShare.TwitterShareButton).length).toEqual(0);
});
});
describe('only twitter enabled', () => {
beforeEach(() => {
mockHooks({ twitter: { isEnabled: true } });
render();
});
testTwitterShareButton();
it('does not render email or facebook controls', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(0);
expect(el.instance.findByType(ReactShare.FacebookShareButton).length).toEqual(0);
});
});
});
});

View File

@@ -1,8 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1`] = `
exports[`CourseCardMenu render show dropdown hide unenroll item and disable email snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown
onToggle={[MockFunction hooks.handleToggleDropdown]}
>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu>
<SocialShareMenu
cardId="test-card-id"
emailSettings={
Object {
"hide": [MockFunction emailSettingHide],
"isVisible": false,
"show": [MockFunction emailSettingShow],
}
}
/>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu render show dropdown show unenroll and enable email snapshot 1`] = `
<Fragment>
<Dropdown
onToggle={[MockFunction hooks.handleToggleDropdown]}
>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
@@ -19,31 +55,16 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
>
Unenroll
</Dropdown.Item>
<Dropdown.Item
data-testid="emailSettingsModalToggle"
disabled={false}
onClick={[MockFunction emailSettingShow]}
>
Email settings
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
>
Share to Facebook
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
>
Share to Twitter
</TwitterShareButton>
<SocialShareMenu
cardId="test-card-id"
emailSettings={
Object {
"hide": [MockFunction emailSettingHide],
"isVisible": false,
"show": [MockFunction emailSettingShow],
}
}
/>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
@@ -58,83 +79,3 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
/>
</Fragment>
`;
exports[`CourseCardMenu masquerading snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu>
<Dropdown.Item
data-testid="unenrollModalToggle"
disabled={true}
onClick={[MockFunction unenrollShow]}
>
Unenroll
</Dropdown.Item>
<Dropdown.Item
data-testid="emailSettingsModalToggle"
disabled={true}
onClick={[MockFunction emailSettingShow]}
>
Email settings
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
>
Share to Facebook
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
>
Share to Twitter
</TwitterShareButton>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
<EmailSettingsModal
cardId="test-card-id"
closeModal={[MockFunction emailSettingHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu not enrolled, share disabled, email setting disabled snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu />
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;

View File

@@ -1,18 +1,15 @@
import React from 'react';
import { StrictDict } from 'utils';
import { useKeyedState, StrictDict } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import * as module from './hooks';
export const state = StrictDict({
isUnenrollConfirmVisible: (val) => React.useState(val), // eslint-disable-line
isEmailSettingsVisible: (val) => React.useState(val), // eslint-disable-line
export const stateKeys = StrictDict({
isUnenrollConfirmVisible: 'isUnenrollConfirmVisible',
isEmailSettingsVisible: 'isEmailSettingsVisible',
});
export const useUnenrollData = () => {
const [isVisible, setIsVisible] = module.state.isUnenrollConfirmVisible(false);
const [isVisible, setIsVisible] = useKeyedState(stateKeys.isUnenrollConfirmVisible, false);
return {
show: () => setIsVisible(true),
hide: () => setIsVisible(false),
@@ -21,7 +18,7 @@ export const useUnenrollData = () => {
};
export const useEmailSettings = () => {
const [isVisible, setIsVisible] = module.state.isEmailSettingsVisible(false);
const [isVisible, setIsVisible] = useKeyedState(stateKeys.isEmailSettingsVisible, false);
return {
show: () => setIsVisible(true),
hide: () => setIsVisible(false),
@@ -30,9 +27,30 @@ export const useEmailSettings = () => {
};
export const useHandleToggleDropdown = (cardId) => {
const eventName = track.course.courseOptionsDropdownClicked;
const trackCourseEvent = reduxHooks.useTrackCourseEvent(eventName, cardId);
const trackCourseEvent = reduxHooks.useTrackCourseEvent(
track.course.courseOptionsDropdownClicked,
cardId,
);
return (isOpen) => {
if (isOpen) { trackCourseEvent(); }
};
};
export const useOptionVisibility = (cardId) => {
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
const shouldShowUnenrollItem = isEnrolled && !isEarned;
const shouldShowDropdown = (
shouldShowUnenrollItem
|| isEmailEnabled
|| facebook.isEnabled
|| twitter.isEnabled
);
return {
shouldShowUnenrollItem,
shouldShowDropdown,
};
};

View File

@@ -1,4 +1,5 @@
import { MockUseState } from 'testUtils';
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import track from 'tracking';
@@ -6,71 +7,77 @@ import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
const trackCourseEvent = jest.fn();
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent);
const state = new MockUseState(hooks);
const cardId = 'test-card-id';
let out;
describe('CourseCardMenu hooks', () => {
describe('state values', () => {
state.testGetter(state.keys.isUnenrollConfirmVisible);
state.testGetter(state.keys.isEmailSettingsVisible);
});
const state = mockUseKeyedState(hooks.stateKeys);
describe('CourseCardMenu hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('useUnenrollData', () => {
beforeEach(() => {
state.mock();
state.mockVals({ isUnenrollConfirmVisible: true });
out = hooks.useUnenrollData();
});
afterEach(state.restore);
test('default state', () => {
expect(out.isVisible).toEqual(state.stateVals.isUnenrollConfirmVisible);
describe('behavior', () => {
it('initializes isUnenrollConfirmVisible state to false', () => {
state.expectInitializedWith(state.keys.isUnenrollConfirmVisible, false);
});
});
test('show', () => {
out.show();
state.expectSetStateCalledWith(state.keys.isUnenrollConfirmVisible, true);
});
test('hide', () => {
out.hide();
state.expectSetStateCalledWith(state.keys.isUnenrollConfirmVisible, false);
describe('output', () => {
test('state is loaded from current state value', () => {
expect(out.isVisible).toEqual(true);
});
test('show sets state value to true', () => {
out.show();
expect(state.setState.isUnenrollConfirmVisible).toHaveBeenCalledWith(true);
});
test('hide sets state value to false', () => {
out.hide();
expect(state.setState.isUnenrollConfirmVisible).toHaveBeenCalledWith(false);
});
});
});
describe('useEmailSettings', () => {
beforeEach(() => {
state.mock();
state.mockVals({ isEmailSettingsVisible: true });
out = hooks.useEmailSettings();
});
afterEach(state.restore);
test('default state', () => {
expect(out.isVisible).toEqual(state.stateVals.isEmailSettingsVisible);
describe('behavior', () => {
it('initializes isEmailSettingsVisible state to false', () => {
state.expectInitializedWith(state.keys.isEmailSettingsVisible, false);
});
});
test('show', () => {
out.show();
state.expectSetStateCalledWith(state.keys.isEmailSettingsVisible, true);
});
test('hide', () => {
out.hide();
state.expectSetStateCalledWith(state.keys.isEmailSettingsVisible, false);
describe('output', () => {
test('state is loaded from current state value', () => {
expect(out.isVisible).toEqual(state.values.isEmailSettingsVisible);
});
test('show sets state value to true', () => {
out.show();
expect(state.setState.isEmailSettingsVisible).toHaveBeenCalledWith(true);
});
test('hide sets state value to false', () => {
out.hide();
expect(state.setState.isEmailSettingsVisible).toHaveBeenCalledWith(false);
});
});
});
describe('useHandleToggleDropdown', () => {
beforeEach(() => {
out = hooks.useHandleToggleDropdown(cardId);
});
beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); });
describe('behavior', () => {
it('initializes course event tracker with event name and card ID', () => {
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
@@ -88,4 +95,59 @@ describe('CourseCardMenu hooks', () => {
});
});
});
describe('useOptionVisibility', () => {
const mockReduxHooks = (returnVals = {}) => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
isEnrolled: !!returnVals.isEnrolled,
isEmailEnabled: !!returnVals.isEmailEnabled,
});
reduxHooks.useCardCertificateData.mockReturnValueOnce({
isEarned: !!returnVals.isEarned,
});
};
describe('shouldShowUnenrollItem', () => {
it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true);
});
it('returns false if not enrolled', () => {
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
});
it('returns false if enrolled but also earned', () => {
mockReduxHooks({ isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
});
});
describe('shouldShowDropdown', () => {
it('returns false if not enrolled and both email and socials are disabled', () => {
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns false if enrolled but already earned, and both email and socials are disabled', () => {
mockReduxHooks({ isEnrolled: true, isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns true if either social is enabled', () => {
mockReduxHooks({ facebook: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
mockReduxHooks({ twitter: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if email is enabled', () => {
mockReduxHooks({ isEmailEnabled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
});
});
});

View File

@@ -1,44 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as ReactShare from 'react-share';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon, IconButton } from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import { StrictDict } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import CertificatePreviewModal from 'containers/CertificatePreviewModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { reduxHooks } from 'hooks';
import SocialShareMenu from './SocialShareMenu';
import {
useEmailSettings,
useUnenrollData,
useHandleToggleDropdown,
useOptionVisibility,
} from './hooks';
import messages from './messages';
export const testIds = StrictDict({
unenrollModalToggle: 'unenrollModalToggle',
});
export const CourseCardMenu = ({ cardId }) => {
const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const handleTwitterShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'twitter',
);
const handleFacebookShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'facebook',
);
const emailSettingsModal = useEmailSettings();
const emailSettings = useEmailSettings();
const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId);
const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const showCertificatePreviewModal = reduxHooks.useShowCertificatePreviewModal(cardId);
if (!shouldShowDropdown) {
return null;
}
return (
<>
@@ -52,52 +51,16 @@ export const CourseCardMenu = ({ cardId }) => {
alt={formatMessage(messages.dropdownAlt)}
/>
<Dropdown.Menu>
{isEnrolled && (
{shouldShowUnenrollItem && (
<Dropdown.Item
disabled={isMasquerading}
onClick={unenrollModal.show}
data-testid="unenrollModalToggle"
data-testid={testIds.unenrollModalToggle}
>
{formatMessage(messages.unenroll)}
</Dropdown.Item>
)}
{isEmailEnabled && (
<Dropdown.Item
disabled={isMasquerading}
onClick={emailSettingsModal.show}
data-testid="emailSettingsModalToggle"
>
{formatMessage(messages.emailSettings)}
</Dropdown.Item>
)}
{facebook.isEnabled && (
<ReactShare.FacebookShareButton
url={facebook.shareUrl}
onClick={handleFacebookShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: facebook.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton>
)}
{twitter.isEnabled && (
<ReactShare.TwitterShareButton
url={twitter.shareUrl}
onClick={handleTwitterShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: twitter.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton>
)}
<SocialShareMenu cardId={cardId} emailSettings={emailSettings} />
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
@@ -107,8 +70,13 @@ export const CourseCardMenu = ({ cardId }) => {
/>
{isEmailEnabled && (
<EmailSettingsModal
show={emailSettingsModal.isVisible}
closeModal={emailSettingsModal.hide}
show={emailSettings.isVisible}
closeModal={emailSettings.hide}
cardId={cardId}
/>
)}
{showCertificatePreviewModal && (
<CertificatePreviewModal
cardId={cardId}
/>
)}

View File

@@ -1,141 +1,213 @@
import { shallow } from 'enzyme';
import { when } from 'jest-when';
import { Dropdown } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { reduxHooks } from 'hooks';
import { useEmailSettings, useUnenrollData } from './hooks';
import CourseCardMenu from '.';
import SocialShareMenu from './SocialShareMenu';
import * as hooks from './hooks';
import CourseCardMenu, { testIds } from '.';
jest.mock('react-share', () => ({
FacebookShareButton: () => 'FacebookShareButton',
TwitterShareButton: () => 'TwitterShareButton',
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: jest.fn().mockReturnValue({
formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage,
}),
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useMasqueradeData: jest.fn(),
useTrackCourseEvent: (_, __, site) => jest.fn().mockName(`${site}ShareClick`),
},
reduxHooks: { useMasqueradeData: jest.fn(), useCardEnrollmentData: jest.fn() },
}));
jest.mock('./SocialShareMenu', () => 'SocialShareMenu');
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
useUnenrollData: jest.fn(),
useHandleToggleDropdown: jest.fn(),
useOptionVisibility: jest.fn(),
}));
const props = {
cardId: 'test-card-id',
};
const defaultEmailSettingsModal = {
const emailSettings = {
isVisible: false,
show: jest.fn().mockName('emailSettingShow'),
hide: jest.fn().mockName('emailSettingHide'),
};
const defaultUnenrollModal = {
const unenrollData = {
isVisible: false,
show: jest.fn().mockName('unenrollShow'),
hide: jest.fn().mockName('unenrollHide'),
};
const defaultSocialShare = {
facebook: {
isEnabled: true,
shareUrl: 'facebook-share-url',
socialBrand: 'facebook-social-brand',
},
twitter: {
isEnabled: true,
shareUrl: 'twitter-share-url',
socialBrand: 'twitter-social-brand',
},
};
const courseName = 'test-course-name';
let wrapper;
let el;
const mockHook = (fn, returnValue, options = {}) => {
if (options.isCardHook) {
when(fn).calledWith(props.cardId).mockReturnValueOnce(returnValue);
} else {
when(fn).calledWith().mockReturnValueOnce(returnValue);
}
};
const handleToggleDropdown = jest.fn().mockName('hooks.handleToggleDropdown');
const mockHooks = (returnVals = {}) => {
mockHook(
hooks.useEmailSettings,
returnVals.emailSettings ? returnVals.emailSettings : emailSettings,
);
mockHook(
hooks.useUnenrollData,
returnVals.unenrollData ? returnVals.unenrollData : unenrollData,
);
mockHook(hooks.useHandleToggleDropdown, handleToggleDropdown, { isCardHook: true });
mockHook(
hooks.useOptionVisibility,
{
shouldShowUnenrollItem: !!returnVals.shouldShowUnenrollItem,
shouldShowDropdown: !!returnVals.shouldShowDropdown,
},
{ isCardHook: true },
);
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
reduxHooks.useCardEnrollmentData,
{ isEmailEnabled: !!returnVals.isEmailEnabled },
{ isCardHook: true },
);
};
const render = () => {
el = shallow(<CourseCardMenu {...props} />);
};
describe('CourseCardMenu', () => {
beforeEach(() => {
useEmailSettings.mockReturnValue(defaultEmailSettingsModal);
useUnenrollData.mockReturnValue(defaultUnenrollModal);
reduxHooks.useCardSocialSettingsData.mockReturnValue(defaultSocialShare);
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: true, isEmailEnabled: true });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
});
describe('enrolled, share enabled, email setting enable', () => {
describe('behavior', () => {
beforeEach(() => {
wrapper = shallow(<CourseCardMenu {...props} />);
mockHooks();
render();
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalledWith();
});
it('renders share buttons', () => {
el = wrapper.find('FacebookShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('facebook-share-url');
el = wrapper.find('TwitterShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('twitter-share-url');
it('initializes local hooks', () => {
when(hooks.useEmailSettings).expectCalledWith();
when(hooks.useUnenrollData).expectCalledWith();
when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId);
when(hooks.useOptionVisibility).expectCalledWith(props.cardId);
});
it('renders enabled unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.props().disabled).toEqual(false);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
it('initializes redux hook data ', () => {
when(reduxHooks.useMasqueradeData).expectCalledWith();
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
});
});
describe('not enrolled, share disabled, email setting disabled', () => {
beforeEach(() => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
...defaultSocialShare,
twitter: { ...defaultSocialShare.twitter, isEnabled: false },
facebook: { ...defaultSocialShare.facebook, isEnabled: false },
describe('render', () => {
it('renders null if showDropdown is false', () => {
mockHooks();
render();
expect(el.isEmptyRender()).toEqual(true);
});
const testHandleToggle = () => {
it('displays Dropdown with onToggle=handleToggleDropdown', () => {
expect(el.instance.findByType(Dropdown)[0].props.onToggle).toEqual(handleToggleDropdown);
});
};
const testUnenrollConfirmModal = () => {
it('displays UnenrollConfirmModal with cardId and unenrollModal data', () => {
const modal = el.instance.findByType(UnenrollConfirmModal)[0];
expect(modal.props.show).toEqual(unenrollData.isVisible);
expect(modal.props.closeModal).toEqual(unenrollData.hide);
expect(modal.props.cardId).toEqual(props.cardId);
});
};
const testSocialShareMenu = () => {
it('displays SocialShareMenu with cardID and emailSettings', () => {
const menu = el.instance.findByType(SocialShareMenu)[0];
expect(menu.props.cardId).toEqual(props.cardId);
expect(menu.props.emailSettings).toEqual(emailSettings);
});
};
describe('show dropdown', () => {
describe('hide unenroll item and disable email', () => {
beforeEach(() => {
mockHooks({ shouldShowDropdown: true });
render();
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
testHandleToggle();
testSocialShareMenu();
it('does not render unenroll modal toggle', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(0);
});
it('does not render EmailSettingsModal', () => {
expect(el.instance.findByType(EmailSettingsModal).length).toEqual(0);
});
testUnenrollConfirmModal();
});
describe('show unenroll and enable email', () => {
const hookProps = {
shouldShowDropdown: true,
isEmailEnabled: true,
shouldShowUnenrollItem: true,
};
beforeEach(() => {
mockHooks(hookProps);
render();
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
testHandleToggle();
testSocialShareMenu();
describe('unenroll modal toggle', () => {
let toggle;
describe('not masquerading', () => {
beforeEach(() => {
mockHooks(hookProps);
render();
[toggle] = el.instance.findByTestId(testIds.unenrollModalToggle);
});
it('renders unenroll modal toggle', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(1);
});
test('onClick from unenroll modal hook', () => {
expect(toggle.props.onClick).toEqual(unenrollData.show);
});
test('disabled', () => {
expect(toggle.props.disabled).toEqual(false);
});
});
describe('masquerading', () => {
beforeEach(() => {
mockHooks({ ...hookProps, isMasquerading: true });
render();
[toggle] = el.instance.findByTestId(testIds.unenrollModalToggle);
});
it('renders', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(1);
});
test('onClick from unenroll modal hook', () => {
expect(toggle.props.onClick).toEqual(unenrollData.show);
});
test('disabled', () => {
expect(toggle.props.disabled).toEqual(true);
});
});
});
testUnenrollConfirmModal();
it('displays EmaiSettingsModal with cardId and emailSettingsModal data', () => {
const modal = el.instance.findByType(EmailSettingsModal)[0];
expect(modal.props.show).toEqual(emailSettings.isVisible);
expect(modal.props.closeModal).toEqual(emailSettings.hide);
expect(modal.props.cardId).toEqual(props.cardId);
});
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false, isEmailEnabled: false });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('does not renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(0);
expect(wrapper.find('TwitterShareButton').length).toEqual(0);
});
it('does not render unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.length).toEqual(0);
});
});
describe('masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(1);
el = wrapper.find('TwitterShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('twitter-share-url');
});
it('renders disabled unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.props().disabled).toEqual(true);
});
it('renders disabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(true);
});
});
});

View File

@@ -3,25 +3,33 @@ import PropTypes from 'prop-types';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from './hooks';
const { courseTitleClicked } = track.course;
export const CourseCardTitle = ({ cardId }) => {
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const handleTitleClicked = reduxHooks.useTrackCourseEvent(courseTitleClicked, cardId, homeUrl);
const handleTitleClicked = reduxHooks.useTrackCourseEvent(
courseTitleClicked,
cardId,
homeUrl,
);
const { disableCourseTitle } = useActionDisabledState(cardId);
return (
<h3>
<a
href={homeUrl}
className="course-card-title"
data-testid="CourseCardTitle"
onClick={handleTitleClicked}
disabled={isEntitlement && !isFulfilled}
>
{courseName}
</a>
{disableCourseTitle ? (
<span className="course-card-title" data-testid="CourseCardTitle">{courseName}</span>
) : (
<a
href={homeUrl}
className="course-card-title"
data-testid="CourseCardTitle"
onClick={handleTitleClicked}
>
{courseName}
</a>
)}
</h3>
);
};

View File

@@ -0,0 +1,67 @@
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from './hooks';
import CourseCardTitle from './CourseCardTitle';
const homeUrl = 'home-url';
jest.mock('tracking', () => ({
course: {
courseTitleClicked: jest.fn().mockName('segment.courseTitleClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(() => ({ courseName: 'course-name' })),
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
useTrackCourseEvent: jest.fn((eventName, cardId, upgradeUrl) => ({
trackCourseEvent: { eventName, cardId, upgradeUrl },
})),
},
}));
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
describe('CourseCardTitle', () => {
const props = {
cardId: 'cardId',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('snapshot', () => {
test('renders clickable link course title', () => {
const wrapper = shallow(<CourseCardTitle {...props} />);
expect(wrapper).toMatchSnapshot();
const title = wrapper.find('.course-card-title');
expect(title.type()).toBe('a');
expect(title.prop('onClick')).toEqual(
reduxHooks.useTrackCourseEvent(
track.course.courseTitleClicked,
props.cardId,
homeUrl,
),
);
});
test('renders disabled link', () => {
useActionDisabledState.mockReturnValueOnce({ disableCourseTitle: true });
const wrapper = shallow(<CourseCardTitle {...props} />);
expect(wrapper).toMatchSnapshot();
const title = wrapper.find('.course-card-title');
expect(title.type()).toBe('span');
expect(title.prop('onClick')).toBeUndefined();
});
});
describe('behavior', () => {
it('initializes', () => {
shallow(<CourseCardTitle {...props} />);
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(
props.cardId,
);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
});

View File

@@ -17,9 +17,8 @@ const cardId = 'test-card-id';
const state = new MockUseState(hooks);
const numPrograms = 27;
const { formatMessage } = useIntl();
describe('RelatedProgramsBadge hooks', () => {
const { formatMessage } = useIntl();
let out;
describe('state values', () => {
state.testGetter(state.keys.isOpen);

View File

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardImage snapshot renders clickable link course Image 1`] = `
<a
className="pgn__card-wrapper-image-cap overflow-visible orientation"
href="home-url"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.courseImageClicked],
"upgradeUrl": "home-url",
},
}
}
tabIndex="-1"
>
<img
alt="Course thumbnail"
className="pgn__card-image-cap show"
src="banner-img-src"
/>
<span
className="course-card-verify-ribbon-container"
title="You're enrolled as a verified student"
>
<Badge
as="div"
className="w-100"
variant="success"
>
Verified
</Badge>
<img
alt="ID Verified Ribbon/Badge"
src="test-file-stub"
/>
</span>
</a>
`;
exports[`CourseCardImage snapshot renders disabled link 1`] = `
<div
className="pgn__card-wrapper-image-cap overflow-visible orientation"
>
<img
alt="Course thumbnail"
className="pgn__card-image-cap show"
src="banner-img-src"
/>
<span
className="course-card-verify-ribbon-container"
title="You're enrolled as a verified student"
>
<Badge
as="div"
className="w-100"
variant="success"
>
Verified
</Badge>
<img
alt="ID Verified Ribbon/Badge"
src="test-file-stub"
/>
</span>
</div>
`;

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardTitle snapshot renders clickable link course title 1`] = `
<h3>
<a
className="course-card-title"
data-testid="CourseCardTitle"
href="home-url"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.courseTitleClicked],
"upgradeUrl": "home-url",
},
}
}
>
course-name
</a>
</h3>
`;
exports[`CourseCardTitle snapshot renders disabled link 1`] = `
<h3>
<span
className="course-card-title"
data-testid="CourseCardTitle"
>
course-name
</span>
</h3>
`;

View File

@@ -0,0 +1,32 @@
import { reduxHooks } from 'hooks';
export const useActionDisabledState = (cardId) => {
const { isMasquerading } = reduxHooks.useMasqueradeData();
const {
canUpgrade, hasAccess, isAudit, isAuditAccessExpired,
} = reduxHooks.useCardEnrollmentData(cardId);
const {
isEntitlement, isFulfilled, canChange, hasSessions,
} = reduxHooks.useCardEntitlementData(cardId);
const { resumeUrl, homeUrl, upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired);
const disableSelectSession = !isEntitlement || isMasquerading || !hasAccess || (!canChange || !hasSessions);
const disableUpgradeCourse = !upgradeUrl || (isMasquerading && !canUpgrade);
const disableCourseTitle = (isEntitlement && !isFulfilled) || disableViewCourse;
return {
disableBeginCourse,
disableResumeCourse,
disableViewCourse,
disableUpgradeCourse,
disableSelectSession,
disableCourseTitle,
};
};
export default useActionDisabledState;

View File

@@ -0,0 +1,186 @@
import { reduxHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
}));
const cardId = 'my-test-course-number';
describe('useActionDisabledState', () => {
const defaultData = {
isMasquerading: false,
canUpgrade: false,
isEntitlement: false,
isFulfilled: false,
canChange: false,
hasSessions: false,
hasAccess: false,
isAudit: false,
isAuditAccessExpired: false,
resumeUrl: 'resume.url',
homeUrl: 'home.url',
upgradeUrl: 'upgrade.url',
};
const mockHooksData = (args) => {
const {
isMasquerading,
canUpgrade,
isEntitlement,
isFulfilled,
canChange,
hasSessions,
hasAccess,
isAudit,
isAuditAccessExpired,
resumeUrl,
homeUrl,
upgradeUrl,
} = { ...defaultData, ...args };
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
canUpgrade,
hasAccess,
isAudit,
isAuditAccessExpired,
});
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
isEntitlement,
isFulfilled,
canChange,
hasSessions,
});
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
resumeUrl,
homeUrl,
upgradeUrl,
});
};
const runHook = () => hooks.useActionDisabledState(cardId);
describe('disableBeginCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableBeginCourse).toBe(expected);
};
it('disable when homeUrl is invalid', () => {
testDisabled({ homeUrl: null }, true);
});
it('disable when isMasquerading is true', () => {
testDisabled({ isMasquerading: true }, true);
});
it('disable when hasAccess is false', () => {
testDisabled({ hasAccess: false }, true);
});
it('disable when isAudit is true and isAuditAccessExpired is true', () => {
testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ hasAccess: true }, false);
});
});
describe('disableResumeCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableResumeCourse).toBe(expected);
};
it('disable when resumeUrl is invalid', () => {
testDisabled({ resumeUrl: null }, true);
});
it('disable when isMasquerading is true', () => {
testDisabled({ isMasquerading: true }, true);
});
it('disable when hasAccess is false', () => {
testDisabled({ hasAccess: false }, true);
});
it('disable when isAudit is true and isAuditAccessExpired is true', () => {
testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ hasAccess: true }, false);
});
});
describe('disableViewCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableViewCourse).toBe(expected);
};
it('disable when hasAccess is false', () => {
testDisabled({ hasAccess: false }, true);
});
it('disable when isAudit is true and isAuditAccessExpired is true', () => {
testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ hasAccess: true }, false);
});
});
describe('disableUpgradeCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableUpgradeCourse).toBe(expected);
};
it('disable when upgradeUrl is invalid', () => {
testDisabled({ upgradeUrl: null }, true);
});
it('disable when isMasquerading is true and canUpgrade is false', () => {
testDisabled({ isMasquerading: true, canUpgrade: false }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ canUpgrade: true }, false);
});
});
describe('disableSelectSession', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableSelectSession).toBe(expected);
};
it('disable when isEntitlement is false', () => {
testDisabled({ isEntitlement: false }, true);
});
it('disable when isMasquerading is true', () => {
testDisabled({ isMasquerading: true }, true);
});
it('disable when hasAccess is false', () => {
testDisabled({ hasAccess: false }, true);
});
it('disable when canChange is false', () => {
testDisabled({ canChange: false }, true);
});
it('disable when hasSessions is false', () => {
testDisabled({ hasSessions: false }, true);
});
it('enable when all conditions are met', () => {
testDisabled(
{
isEntitlement: true,
hasAccess: true,
canChange: true,
hasSessions: true,
},
false,
);
});
});
describe('disableCourseTitle', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableCourseTitle).toBe(expected);
};
it('disable when isEntitlement is true and isFulfilled is false', () => {
testDisabled({ isEntitlement: true, isFulfilled: false }, true);
});
it('disable when disableViewCourse is true', () => {
testDisabled({ hasAccess: false }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ isEntitlement: true, isFulfilled: true, hasAccess: true }, false);
});
});
});

View File

@@ -17,7 +17,7 @@ exports[`NoCoursesView snapshot 1`] = `
</p>
<Button
as="a"
href="course-search-url"
href="http://localhost:18000/course-search-url"
iconBefore={[MockFunction icons.Search]}
variant="brand"
>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Image } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { baseAppUrl } from 'data/services/lms/urls';
import emptyCourseSVG from 'assets/empty-course.svg';
import { reduxHooks } from 'hooks';
@@ -27,7 +28,7 @@ export const NoCoursesView = () => {
<Button
variant="brand"
as="a"
href={courseSearchUrl}
href={baseAppUrl(courseSearchUrl)}
iconBefore={Search}
>
{formatMessage(messages.exploreCoursesButton)}

View File

@@ -6,7 +6,7 @@ import EmptyCourse from '.';
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl: 'course-search-url',
courseSearchUrl: '/course-search-url',
})),
},
}));

View File

@@ -3,12 +3,19 @@ import PropTypes from 'prop-types';
import { Container, Col, Row } from '@edx/paragon';
import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks';
export const columnConfig = {
courseList: {
lg: { span: 12, offset: 0 },
xl: { span: 8, offset: 0 },
withSidebar: {
lg: { span: 12, offset: 0 },
xl: { span: 8, offset: 0 },
},
noSidebar: {
lg: { span: 12, offset: 0 },
xl: { span: 12, offset: 0 },
},
},
sidebar: {
lg: { span: 12, offset: 0 },
@@ -16,18 +23,31 @@ export const columnConfig = {
},
};
export const DashboardLayout = ({ children, sidebar }) => {
const isCollapsed = hooks.useIsDashboardCollapsed();
export const DashboardLayout = ({ children, sidebar: Sidebar }) => {
const {
isCollapsed,
sidebarShowing,
setSidebarShowing,
} = hooks.useDashboardLayoutData();
const courseListColumnProps = sidebarShowing
? columnConfig.courseList.withSidebar
: columnConfig.courseList.noSidebar;
return (
<Container fluid size="xl">
<Row>
<Col {...columnConfig.courseList} className="course-list-column">
<Col {...courseListColumnProps} className="course-list-column">
{children}
</Col>
<Col {...columnConfig.sidebar} className="sidebar-column">
{!isCollapsed && (<h2 className="course-list-title">&nbsp;</h2>)}
{sidebar}
<Sidebar setSidebarShowing={setSidebarShowing} />
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
@@ -35,7 +55,7 @@ export const DashboardLayout = ({ children, sidebar }) => {
};
DashboardLayout.propTypes = {
children: PropTypes.node.isRequired,
sidebar: PropTypes.node.isRequired,
sidebar: PropTypes.func.isRequired,
};
export default DashboardLayout;

View File

@@ -1,60 +1,125 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Col, Row } from '@edx/paragon';
import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks';
import DashboardLayout, { columnConfig } from './DashboardLayout';
jest.mock('./hooks', () => ({
useIsDashboardCollapsed: jest.fn(() => true),
useDashboardLayoutData: jest.fn(),
}));
const hookProps = {
isCollapsed: true,
sidebarShowing: false,
setSidebarShowing: jest.fn().mockName('hooks.setSidebarShowing'),
};
hooks.useDashboardLayoutData.mockReturnValue(hookProps);
const props = {
sidebar: jest.fn(() => 'test-sidebar-content'),
};
const children = 'test-children';
let el;
describe('DashboardLayout', () => {
const children = 'test-children';
const props = {
sidebar: 'test-sidebar-content',
};
const render = () => shallow(<DashboardLayout sidebar={props.sidebar}>{children}</DashboardLayout>);
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<DashboardLayout {...props}>{children}</DashboardLayout>);
});
const testColumns = () => {
it('loads courseList and sidebar column layout', () => {
const columns = render().find(Row).find(Col);
Object.keys(columnConfig.courseList).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList[size]);
});
const columns = el.find(Row).find(Col);
Object.keys(columnConfig.sidebar).forEach(size => {
expect(columns.at(1).props()[size]).toEqual(columnConfig.sidebar[size]);
});
});
it('displays children in first column', () => {
const columns = render().find(Row).find(Col);
const columns = el.find(Row).find(Col);
expect(columns.at(0).contains(children)).toEqual(true);
});
it('displays sidebar prop in second column', () => {
const columns = render().find(Row).find(Col);
expect(columns.at(1).contains(props.sidebar)).toEqual(true);
const columns = el.find(Row).find(Col);
expect(columns.at(1).find(props.sidebar)).toHaveLength(1);
});
it('displays a footer in the second row', () => {
const columns = el.find(Row).at(1).find(Col);
expect(columns.at(0).containsMatchingElement(<WidgetFooter />)).toBeTruthy();
});
};
const testSidebarLayout = () => {
it('displays widthSidebar width for course list column', () => {
const columns = el.find(Row).find(Col);
Object.keys(columnConfig.courseList.withSidebar).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.withSidebar[size]);
});
});
};
const testNoSidebarLayout = () => {
it('displays noSidebar width for course list column', () => {
const columns = el.find(Row).find(Col);
Object.keys(columnConfig.courseList.noSidebar).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.noSidebar[size]);
});
});
};
const testSnapshot = () => {
test('snapshot', () => {
expect(render()).toMatchSnapshot();
expect(el).toMatchSnapshot();
});
};
describe('collapsed', () => {
testColumns();
testSnapshot();
describe('sidebar showing', () => {
beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, sidebarShowing: true });
});
testColumns();
testSnapshot();
testSidebarLayout();
});
describe('sidebar not showing', () => {
testColumns();
testSnapshot();
testNoSidebarLayout();
});
it('does not show spacer component above widget sidebar', () => {
const columns = render().find(Col);
const columns = el.find(Col);
expect(columns.at(1).find('h2').length).toEqual(0);
});
});
describe('not collapsed', () => {
beforeEach(() => { hooks.useIsDashboardCollapsed.mockReturnValueOnce(false); });
testColumns();
testSnapshot();
it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => {
const columns = render().find(Col);
// nonbreaking space equivalent
expect(columns.at(1).find('h2').text()).toEqual('\xA0');
const testWidgetSpacing = () => {
it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => {
const columns = el.find(Col);
// nonbreaking space equivalent
expect(columns.at(1).find('h2').text()).toEqual('\xA0');
});
};
describe('sidebar showing', () => {
beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({
...hookProps,
isCollapsed: false,
sidebarShowing: true,
});
});
testColumns();
testSnapshot();
testSidebarLayout();
testWidgetSpacing();
});
describe('sidebar not showing', () => {
beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, isCollapsed: false });
});
testColumns();
testSnapshot();
testNoSidebarLayout();
testWidgetSpacing();
});
});
});

View File

@@ -1,6 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DashboardLayout collapsed snapshot 1`] = `
exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
>
<Row>
<Col
className="course-list-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 12,
}
}
>
test-children
</Col>
<Col
className="sidebar-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 4,
}
}
>
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
`;
exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
@@ -38,13 +89,76 @@ exports[`DashboardLayout collapsed snapshot 1`] = `
}
}
>
test-sidebar-content
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
`;
exports[`DashboardLayout not collapsed snapshot 1`] = `
exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
>
<Row>
<Col
className="course-list-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 12,
}
}
>
test-children
</Col>
<Col
className="sidebar-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 4,
}
}
>
<h2
className="course-list-title"
>
 
</h2>
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
`;
exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
@@ -87,7 +201,14 @@ exports[`DashboardLayout not collapsed snapshot 1`] = `
>
 
</h2>
test-sidebar-content
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>

View File

@@ -15,7 +15,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
id="dashboard-content"
>
<DashboardLayout
sidebar={<LoadedWidgetSidebar />}
sidebar="LoadedWidgetSidebar"
>
<CourseList />
</DashboardLayout>
@@ -56,7 +56,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
id="dashboard-content"
>
<DashboardLayout
sidebar={<NoCoursesWidgetSidebar />}
sidebar="NoCoursesWidgetSidebar"
>
<CourseList />
</DashboardLayout>

View File

@@ -2,13 +2,14 @@ import React from 'react';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { apiHooks } from 'hooks';
import { StrictDict } from 'utils';
import appMessages from 'messages';
import * as module from './hooks';
export const useIsDashboardCollapsed = () => {
const { width } = useWindowSize();
return width < breakpoints.large.maxWidth;
};
export const state = StrictDict({
sidebarShowing: (val) => React.useState(val), // eslint-disable-line
});
export const useInitializeDashboard = () => {
const initialize = apiHooks.useInitializeApp();
@@ -23,8 +24,18 @@ export const useDashboardMessages = () => {
};
};
export const useDashboardLayoutData = () => {
const { width } = useWindowSize();
const [sidebarShowing, setSidebarShowing] = module.state.sidebarShowing(false);
return {
isDashboardCollapsed: width < breakpoints.large.maxWidth,
sidebarShowing,
setSidebarShowing,
};
};
export default {
useIsDashboardCollapsed,
useDashboardLayoutData,
useInitializeDashboard,
useDashboardMessages,
};

View File

@@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { apiHooks } from 'hooks';
import { MockUseState } from 'testUtils';
import appMessages from 'messages';
import * as hooks from './hooks';
@@ -19,8 +20,12 @@ jest.mock('hooks', () => ({
},
}));
const state = new MockUseState(hooks);
const initializeApp = jest.fn();
apiHooks.useInitializeApp.mockReturnValue(initializeApp);
useWindowSize.mockReturnValue({ width: 20 });
breakpoints.large = { maxWidth: 30 };
describe('CourseCard hooks', () => {
const { formatMessage } = useIntl();
@@ -28,15 +33,32 @@ describe('CourseCard hooks', () => {
jest.clearAllMocks();
});
describe('useIsDashboardCollapsed', () => {
it('returns true iff windowSize width is below the xl breakpoint', () => {
useWindowSize.mockReturnValueOnce({ width: 20 });
breakpoints.large = { maxWidth: 30 };
expect(hooks.useIsDashboardCollapsed()).toEqual(true);
useWindowSize.mockReturnValueOnce({ width: 40 });
expect(hooks.useIsDashboardCollapsed()).toEqual(false);
useWindowSize.mockReturnValueOnce({ width: 40 });
expect(hooks.useIsDashboardCollapsed()).toEqual(false);
describe('state fields', () => {
state.testGetter(state.keys.sidebarShowing);
});
describe('useDashboardLayoutData', () => {
beforeEach(() => { state.mock(); });
describe('behavior', () => {
it('initializes sidebarShowing to default false value', () => {
hooks.useDashboardLayoutData();
state.expectInitializedWith(state.keys.sidebarShowing, false);
});
});
describe('output', () => {
describe('isDashboardCollapsed', () => {
it('returns true iff windowSize width is below the xl breakpoint', () => {
expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(true);
useWindowSize.mockReturnValueOnce({ width: 40 });
expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(false);
});
});
it('forwards sidebarShowing and setSidebarShowing from state hook', () => {
const hook = hooks.useDashboardLayoutData();
const { sidebarShowing, setSidebarShowing } = hook;
expect(sidebarShowing).toEqual(state.stateVals.sidebarShowing);
expect(setSidebarShowing).toEqual(state.setState.sidebarShowing);
});
});
});
describe('useInitializeDashboard', () => {

View File

@@ -21,6 +21,7 @@ export const Dashboard = () => {
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
return (
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
<h1 className="sr-only">{pageTitle}</h1>
@@ -34,7 +35,7 @@ export const Dashboard = () => {
{initIsPending
? (<LoadingView />)
: (
<DashboardLayout sidebar={hasCourses ? <LoadedSidebar /> : <NoCoursesSidebar />}>
<DashboardLayout sidebar={hasCourses ? LoadedSidebar : NoCoursesSidebar}>
<CourseList />
</DashboardLayout>
)}

View File

@@ -116,7 +116,7 @@ describe('Dashboard', () => {
showSelectSessionModal: true,
},
content: ['LoadedView', (
<DashboardLayout sidebar={<LoadedWidgetSidebar />}><CourseList /></DashboardLayout>
<DashboardLayout sidebar={LoadedWidgetSidebar}><CourseList /></DashboardLayout>
)],
showEnterpriseModal: false,
showSelectSessionModal: true,
@@ -132,7 +132,7 @@ describe('Dashboard', () => {
showSelectSessionModal: false,
},
content: ['Dashboard layout with no courses sidebar and content', (
<DashboardLayout sidebar={<NoCoursesWidgetSidebar />}><CourseList /></DashboardLayout>
<DashboardLayout sidebar={NoCoursesWidgetSidebar}><CourseList /></DashboardLayout>
)],
showEnterpriseModal: true,
showSelectSessionModal: false,

View File

@@ -34,7 +34,7 @@ exports[`EnterpriseDashboard snapshot 1`] = `
onClick={[MockFunction useEnterpriseDashboardHook.handleCTAClick]}
type="a"
>
Go To Dashboard
Go to dashboard
</Button>
</ActionRow>
</div>

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
enterpriseDialogConfirmButton: {
id: 'leanerDashboard.enterpriseDialogConfirmButton',
defaultMessage: 'Go To Dashboard',
defaultMessage: 'Go to dashboard',
description: 'Confirm button to go to the dashboard url',
},
});

View File

@@ -1,85 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { AvatarButton, Dropdown } from '@edx/paragon';
import urls from 'data/services/lms/urls';
import { reduxHooks } from 'hooks';
import { useIsCollapsed, findCoursesNavDropdownClicked } from './hooks';
import messages from './messages';
export const AuthenticatedUserDropdown = ({ username }) => {
const { formatMessage } = useIntl();
const { authenticatedUser } = React.useContext(AppContext);
const dashboard = reduxHooks.useEnterpriseDashboardData();
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
const isCollapsed = useIsCollapsed();
const { profileImage } = authenticatedUser;
return (
<Dropdown variant={isCollapsed ? 'light' : 'dark'} className="user-dropdown ml-1">
<Dropdown.Toggle
as={AvatarButton}
src={profileImage}
id="user"
variant="primary"
>
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
<Dropdown.Header>SWITCH DASHBOARD</Dropdown.Header>
<Dropdown.Item as="a" href="/edx-dashboard" className="active">Personal</Dropdown.Item>
{!!dashboard && (
<Dropdown.Item
as="a"
href={dashboard.url}
key={dashboard.label}
>
{dashboard.label} {formatMessage(messages.dashboard)}
</Dropdown.Item>
)}
<Dropdown.Divider />
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
{formatMessage(messages.profile)}
</Dropdown.Item>
{isCollapsed && (
<>
<Dropdown.Item href={urls.programsUrl}>
{formatMessage(messages.viewPrograms)}
</Dropdown.Item>
<Dropdown.Item href={courseSearchUrl} onClick={findCoursesNavDropdownClicked(courseSearchUrl)}>
{formatMessage(messages.exploreCourses)}
</Dropdown.Item>
</>
)}
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
{formatMessage(messages.account)}
</Dropdown.Item>
{getConfig().ORDER_HISTORY_URL && (
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().SUPPORT_URL}>
{formatMessage(messages.help)}
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};
AuthenticatedUserDropdown.propTypes = {
username: PropTypes.string.isRequired,
};
export default AuthenticatedUserDropdown;

View File

@@ -1,50 +0,0 @@
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { AuthenticatedUserDropdown } from './AuthenticatedUserDropdown';
import { useIsCollapsed } from './hooks';
jest.mock('@edx/frontend-platform/react', () => ({
AppContext: {
authenticatedUser: {
profileImage: 'profileImage',
},
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useEnterpriseDashboardData: jest.fn(),
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl: 'test-course-search-url',
})),
},
}));
jest.mock('containers/LearnerDashboardHeader/hooks', () => ({
useIsCollapsed: jest.fn(),
findCoursesNavDropdownClicked: (href) => jest.fn().mockName(`findCoursesNavDropdownClicked('${href}')`),
}));
describe('AuthenticatedUserDropdown', () => {
const props = {
username: 'username',
};
const defaultDashboardData = {
label: 'label',
url: 'url',
};
describe('snapshots', () => {
test('with enterprise dashboard', () => {
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce(defaultDashboardData);
useIsCollapsed.mockReturnValueOnce(true);
const wrapper = shallow(<AuthenticatedUserDropdown {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('without enterprise dashboard and expanded', () => {
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce(null);
useIsCollapsed.mockReturnValueOnce(false);
const wrapper = shallow(<AuthenticatedUserDropdown {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import { configuration } from '../../config';
import messages from './messages';
@@ -14,7 +15,7 @@ export const BrandLogo = () => {
<a href={dashboard?.url || '/'} className="mx-auto">
<img
className="logo py-3"
src="https://edx-cdn.org/v3/prod/logo.svg"
src={configuration.LOGO_URL}
alt={formatMessage(messages.logoAltText)}
/>
</a>

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