Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1c35f134c | ||
|
|
41af76f027 | ||
|
|
57a3b0963c | ||
|
|
92dcdd0a98 | ||
|
|
29f0cefc1e | ||
|
|
794a1d57b9 | ||
|
|
fe46e8a1a6 | ||
|
|
a525d3c22e | ||
|
|
81a2c3c0d2 | ||
|
|
0018eafdcc | ||
|
|
e7db2ef753 | ||
|
|
2e986d9b74 | ||
|
|
7c85195a27 | ||
|
|
b686acf5f5 | ||
|
|
166deeafbd | ||
|
|
f33fe7d0e5 | ||
|
|
e2896dbf94 | ||
|
|
f07d266a43 | ||
|
|
69cdc5f191 | ||
|
|
4f51f71acc | ||
|
|
c70eca1fde | ||
|
|
39dc5bbbd2 | ||
|
|
7fa61f3714 | ||
|
|
8ce7c1599d | ||
|
|
03bdcff331 | ||
|
|
d73d840e93 | ||
|
|
d23b5f53df | ||
|
|
da98bfa021 | ||
|
|
c37640aa69 | ||
|
|
ea35227389 | ||
|
|
c35bf95c1c | ||
|
|
bd26928154 | ||
|
|
cdc8efe17b | ||
|
|
8b6535ea58 | ||
|
|
4c9498971a | ||
|
|
a6b6a3f940 | ||
|
|
cab1a24e10 | ||
|
|
f6b7782d24 | ||
|
|
c7bbe8d0d1 | ||
|
|
20fd7ea13b | ||
|
|
d55d38ec12 | ||
|
|
e77c6ee74a | ||
|
|
8cb30bedd8 | ||
|
|
0ab3f5f669 | ||
|
|
9eaab9c2e5 | ||
|
|
ac28626b3c | ||
|
|
3f90fea26c | ||
|
|
a0466852d6 | ||
|
|
9fad507ada | ||
|
|
73351fa8e8 | ||
|
|
c8528a7874 | ||
|
|
124909ab74 | ||
|
|
77f66f3afb | ||
|
|
102288407f | ||
|
|
dd7c35497e | ||
|
|
8331d37b7f | ||
|
|
1d12506b01 | ||
|
|
c22c4ec5a6 | ||
|
|
69fc4be952 | ||
|
|
9d946eacd8 | ||
|
|
0af0935e86 | ||
|
|
fd33842109 | ||
|
|
13b5f3bc12 | ||
|
|
502ad904ea | ||
|
|
594ae27c0e | ||
|
|
0924cb1ba3 | ||
|
|
90ee5800b4 | ||
|
|
6be87b4a82 | ||
|
|
2137e985b3 | ||
|
|
ca93f890e1 | ||
|
|
6a57622a3c | ||
|
|
3d490c3879 | ||
|
|
1e09c83300 | ||
|
|
b660903836 | ||
|
|
3d2b8416f9 | ||
|
|
4afd07201b | ||
|
|
94ead51915 | ||
|
|
587e3f2647 | ||
|
|
3394656ed2 | ||
|
|
66eda1a58d | ||
|
|
c1ccc8c201 | ||
|
|
4ae65cfcee | ||
|
|
ad7e2035bc | ||
|
|
d5d67dbe14 | ||
|
|
3423e5efea | ||
|
|
f3d30925a8 | ||
|
|
1ec2c3b262 | ||
|
|
260df228fb | ||
|
|
6b740a89c6 | ||
|
|
03f8fdbdc3 | ||
|
|
0b86166a57 | ||
|
|
46eefa7592 | ||
|
|
036b4be854 | ||
|
|
eae0bfdca2 | ||
|
|
05f5903cbc | ||
|
|
725ae950f4 | ||
|
|
38c4f3bad3 | ||
|
|
46acf2a5a4 | ||
|
|
e8aafef127 | ||
|
|
cf451770ed | ||
|
|
d9c3975f26 | ||
|
|
5f42857332 | ||
|
|
f6babc2db9 | ||
|
|
67a053f3e0 | ||
|
|
845ab30af5 | ||
|
|
3244ecf70b | ||
|
|
a1390ebf36 | ||
|
|
89f9d9511f | ||
|
|
bc66c74a33 | ||
|
|
d760be1a53 | ||
|
|
929a34a0f6 | ||
|
|
55fc919c6f | ||
|
|
102a93486e | ||
|
|
981ba84163 | ||
|
|
8aa918bfb9 | ||
|
|
4ca2ab9f4e | ||
|
|
c01f1854ee | ||
|
|
987484d205 | ||
|
|
f86496468e | ||
|
|
e916ba29b9 | ||
|
|
6410ce1d8f | ||
|
|
d0eebfa0ea | ||
|
|
77daf2fbad | ||
|
|
354426037e | ||
|
|
b9af9ed700 | ||
|
|
2342eaae82 | ||
|
|
a1484264fb | ||
|
|
cff4a76b0c | ||
|
|
3e2e8095b4 | ||
|
|
ebd63a13a9 | ||
|
|
fd0d08daa1 | ||
|
|
6ae4c2d68b | ||
|
|
95331d1b10 | ||
|
|
7c63b66d8e | ||
|
|
810f506e52 | ||
|
|
33fd669c8f | ||
|
|
5c5204fb17 | ||
|
|
95db89a9dd | ||
|
|
81a878a658 | ||
|
|
b502de846a | ||
|
|
39f0123820 | ||
|
|
7ee70193c0 | ||
|
|
0e1574dba7 | ||
|
|
0d45ae6599 | ||
|
|
78246cf26b | ||
|
|
ca193563ec | ||
|
|
d0efd35e66 | ||
|
|
375b704eef | ||
|
|
ae121358db | ||
|
|
92b7c58af7 | ||
|
|
a4097fe6fc | ||
|
|
397f688300 | ||
|
|
8bd4b1b9a8 | ||
|
|
54d029c181 | ||
|
|
e6a4636147 | ||
|
|
efb4162926 | ||
|
|
6061232e10 | ||
|
|
ba6b8c8f9b | ||
|
|
9c16ba0075 | ||
|
|
02b987909b | ||
|
|
1bcc54bb05 | ||
|
|
5a5b0b905b | ||
|
|
44ed49c7d2 | ||
|
|
386baa3840 | ||
|
|
f1a56ad6bc | ||
|
|
465bb9f7a0 | ||
|
|
f9b7525d44 | ||
|
|
c7e82295c2 | ||
|
|
e02cf28b54 | ||
|
|
18c51e8e73 | ||
|
|
88b444e796 | ||
|
|
71635b33b6 |
6
.env
6
.env
@@ -12,10 +12,11 @@ LOGIN_URL=''
|
|||||||
LOGO_TRADEMARK_URL=''
|
LOGO_TRADEMARK_URL=''
|
||||||
LOGO_URL=''
|
LOGO_URL=''
|
||||||
LOGO_WHITE_URL=''
|
LOGO_WHITE_URL=''
|
||||||
|
SHOW_PUSH_CHANNEL=''
|
||||||
SHOW_EMAIL_CHANNEL=''
|
SHOW_EMAIL_CHANNEL=''
|
||||||
LOGOUT_URL=''
|
LOGOUT_URL=''
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
NODE_ENV=''
|
NODE_ENV='production'
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
PUBLISHER_BASE_URL=''
|
PUBLISHER_BASE_URL=''
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
@@ -33,3 +34,6 @@ MFE_CONFIG_API_URL=
|
|||||||
PASSWORD_RESET_SUPPORT_LINK=''
|
PASSWORD_RESET_SUPPORT_LINK=''
|
||||||
LEARNER_FEEDBACK_URL=''
|
LEARNER_FEEDBACK_URL=''
|
||||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
# Fallback in local style files
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -28,9 +28,13 @@ ENABLE_COPPA_COMPLIANCE=''
|
|||||||
ENABLE_ACCOUNT_DELETION=''
|
ENABLE_ACCOUNT_DELETION=''
|
||||||
ENABLE_DOB_UPDATE=''
|
ENABLE_DOB_UPDATE=''
|
||||||
MARKETING_EMAILS_OPT_IN=''
|
MARKETING_EMAILS_OPT_IN=''
|
||||||
|
SHOW_PUSH_CHANNEL='true'
|
||||||
SHOW_EMAIL_CHANNEL='true'
|
SHOW_EMAIL_CHANNEL='true'
|
||||||
APP_ID=
|
APP_ID=
|
||||||
MFE_CONFIG_API_URL=
|
MFE_CONFIG_API_URL=
|
||||||
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
|
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
|
||||||
LEARNER_FEEDBACK_URL=''
|
LEARNER_FEEDBACK_URL=''
|
||||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
# Fallback in local style files
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SUPPORT_URL='http://localhost:18000/support'
|
|||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
ENABLE_COPPA_COMPLIANCE=''
|
ENABLE_COPPA_COMPLIANCE=''
|
||||||
ENABLE_ACCOUNT_DELETION=''
|
ENABLE_ACCOUNT_DELETION=''
|
||||||
|
SHOW_PUSH_CHANNEL=''
|
||||||
SHOW_EMAIL_CHANNEL=''
|
SHOW_EMAIL_CHANNEL=''
|
||||||
ENABLE_DOB_UPDATE=''
|
ENABLE_DOB_UPDATE=''
|
||||||
MARKETING_EMAILS_OPT_IN=''
|
MARKETING_EMAILS_OPT_IN=''
|
||||||
@@ -31,3 +32,5 @@ APP_ID=
|
|||||||
MFE_CONFIG_API_URL=
|
MFE_CONFIG_API_URL=
|
||||||
LEARNER_FEEDBACK_URL=''
|
LEARNER_FEEDBACK_URL=''
|
||||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
__mocks__/
|
__mocks__/
|
||||||
__snapshots__/
|
__snapshots__/
|
||||||
|
src/i18n/messages/
|
||||||
|
|||||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -20,5 +20,5 @@ Include a link to the sandbox for design changes or screenshot for before and af
|
|||||||
|
|
||||||
#### Post-merge Checklist
|
#### Post-merge Checklist
|
||||||
|
|
||||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
|
* [ ] Deploy the changes to prod after verifying on stage or ask **@jacobo-dominguez-wgu** to do it.
|
||||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||||
7
Makefile
7
Makefile
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
@@ -19,6 +17,11 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
|
|||||||
npm run $(*)
|
npm run $(*)
|
||||||
|
|
||||||
.PHONY: requirements
|
.PHONY: requirements
|
||||||
|
|
||||||
|
precommit:
|
||||||
|
npm run lint
|
||||||
|
npm audit
|
||||||
|
|
||||||
requirements: ## install ci requirements
|
requirements: ## install ci requirements
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
|
|||||||
12
README.rst
12
README.rst
@@ -89,9 +89,9 @@ Cloning and Startup
|
|||||||
|
|
||||||
``git clone https://github.com/openedx/frontend-app-account.git``
|
``git clone https://github.com/openedx/frontend-app-account.git``
|
||||||
|
|
||||||
2. Use node v18.x.
|
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||||
|
|
||||||
The current version of the micro-frontend build scripts support node 18.
|
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
|
||||||
Using other major versions of node *may* work, but this is unsupported. For
|
Using other major versions of node *may* work, but this is unsupported. For
|
||||||
convenience, this repository includes an .nvmrc file to help in setting the
|
convenience, this repository includes an .nvmrc file to help in setting the
|
||||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||||
@@ -104,6 +104,12 @@ Cloning and Startup
|
|||||||
|
|
||||||
``npm start``
|
``npm start``
|
||||||
|
|
||||||
|
Or for local development with custom configuration:
|
||||||
|
|
||||||
|
``npm run dev``
|
||||||
|
|
||||||
|
This runs the dev server with PUBLIC_PATH=/account/, MFE_CONFIG_API_URL pointing to localhost:8000, and hosts on apps.local.openedx.io.
|
||||||
|
|
||||||
Local module development
|
Local module development
|
||||||
=========================
|
=========================
|
||||||
|
|
||||||
@@ -212,7 +218,7 @@ Please do not report security issues in public. Please email security@openedx.or
|
|||||||
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
|
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
|
||||||
:alt: Continuous Integration
|
:alt: Continuous Integration
|
||||||
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
|
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
|
||||||
:target: https://codecov.io/gh/edx/frontend-app-account
|
:target: https://codecov.io/gh/openedx/frontend-app-account/
|
||||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
|
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
|
||||||
:target: @edx/frontend-app-account
|
:target: @edx/frontend-app-account
|
||||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg
|
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ metadata:
|
|||||||
openedx.org/arch-interest-groups: ""
|
openedx.org/arch-interest-groups: ""
|
||||||
openedx.org/release: "master"
|
openedx.org/release: "master"
|
||||||
spec:
|
spec:
|
||||||
owner: group:2u-infinity
|
owner: jacobo-dominguez-wgu
|
||||||
type: 'website'
|
type: 'website'
|
||||||
lifecycle: 'production'
|
lifecycle: 'production'
|
||||||
|
|||||||
15
codecov.yml
Normal file
15
codecov.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
enabled: yes
|
||||||
|
target: auto
|
||||||
|
threshold: 0%
|
||||||
|
patch:
|
||||||
|
default:
|
||||||
|
enabled: yes
|
||||||
|
target: auto
|
||||||
|
threshold: 0%
|
||||||
|
ignore:
|
||||||
|
- "src/i18n"
|
||||||
|
- "src/index.jsx"
|
||||||
10641
package-lock.json
generated
10641
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
|
"dev": "PUBLIC_PATH=/account/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||||
"i18n_extract": "fedx-scripts formatjs extract",
|
"i18n_extract": "fedx-scripts formatjs extract",
|
||||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
"lint:fix": "npm run lint -- --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
@@ -29,26 +30,25 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||||
"@edx/frontend-component-footer": "^14.3.0",
|
"@edx/frontend-component-footer": "^14.6.0",
|
||||||
"@edx/frontend-component-header": "^6.2.0",
|
"@edx/frontend-component-header": "^6.2.0",
|
||||||
"@edx/frontend-platform": "^8.3.3",
|
"@edx/frontend-platform": "^8.4.0",
|
||||||
"@edx/openedx-atlas": "^0.6.0",
|
"@edx/openedx-atlas": "^0.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/react-fontawesome": "0.2.2",
|
"@fortawesome/react-fontawesome": "0.2.6",
|
||||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||||
"@openedx/frontend-slot-footer": "^1.1.0",
|
"@openedx/paragon": "^23.4.5",
|
||||||
"@openedx/paragon": "^22.16.0",
|
|
||||||
"@tensorflow-models/blazeface": "0.1.0",
|
"@tensorflow-models/blazeface": "0.1.0",
|
||||||
"@tensorflow/tfjs-converter": "4.22.0",
|
"@tensorflow/tfjs-converter": "4.22.0",
|
||||||
"@tensorflow/tfjs-core": "4.22.0",
|
"@tensorflow/tfjs-core": "4.22.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.14.1",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"core-js": "3.41.0",
|
"core-js": "3.48.0",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"form-urlencoded": "6.1.5",
|
"form-urlencoded": "6.1.6",
|
||||||
"formdata-polyfill": "4.0.10",
|
"formdata-polyfill": "4.0.10",
|
||||||
"jslib-html5-camera-photo": "3.3.4",
|
"jslib-html5-camera-photo": "3.3.4",
|
||||||
"lodash.camelcase": "4.3.0",
|
"lodash.camelcase": "4.3.0",
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
"lodash.pick": "4.4.0",
|
"lodash.pick": "4.4.0",
|
||||||
"lodash.pickby": "4.6.0",
|
"lodash.pickby": "4.6.0",
|
||||||
"lodash.snakecase": "4.1.1",
|
"lodash.snakecase": "4.1.1",
|
||||||
"long": "5.3.1",
|
"long": "5.3.2",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"qs": "6.14.0",
|
"qs": "6.15.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-helmet": "6.1.0",
|
"react-helmet": "6.1.0",
|
||||||
@@ -77,20 +77,18 @@
|
|||||||
"redux": "4.2.1",
|
"redux": "4.2.1",
|
||||||
"redux-devtools-extension": "2.13.9",
|
"redux-devtools-extension": "2.13.9",
|
||||||
"redux-logger": "3.0.6",
|
"redux-logger": "3.0.6",
|
||||||
"redux-saga": "1.3.0",
|
"redux-saga": "1.4.2",
|
||||||
"redux-thunk": "2.4.2",
|
"redux-thunk": "2.4.2",
|
||||||
"regenerator-runtime": "0.14.1",
|
"regenerator-runtime": "0.14.1",
|
||||||
"reselect": "^5.1.1",
|
"reselect": "^5.1.1",
|
||||||
"universal-cookie": "7.2.2"
|
"universal-cookie": "7.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.5.0",
|
"@edx/browserslist-config": "1.5.1",
|
||||||
"@edx/reactifex": "2.2.0",
|
"@openedx/frontend-build": "^14.6.2",
|
||||||
"@openedx/frontend-build": "^14.3.3",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/jest-dom": "6.6.3",
|
|
||||||
"@testing-library/react": "14.3.1",
|
"@testing-library/react": "14.3.1",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"reactifex": "1.1.1",
|
|
||||||
"redux-mock-store": "1.5.5"
|
"redux-mock-store": "1.5.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getLanguageList,
|
getLanguageList,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Hyperlink, Icon, Alert,
|
Container, Hyperlink, Icon, Alert,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
|
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
@@ -47,10 +47,13 @@ import {
|
|||||||
COPPA_COMPLIANCE_YEAR,
|
COPPA_COMPLIANCE_YEAR,
|
||||||
WORK_EXPERIENCE_OPTIONS,
|
WORK_EXPERIENCE_OPTIONS,
|
||||||
getStatesList,
|
getStatesList,
|
||||||
|
FIELD_LABELS,
|
||||||
} from './data/constants';
|
} from './data/constants';
|
||||||
import { fetchSiteLanguages } from './site-language';
|
import { fetchSiteLanguages } from './site-language';
|
||||||
import { fetchCourseList } from '../notification-preferences/data/thunks';
|
import { fetchNotificationPreferences } from '../notification-preferences/data/thunks';
|
||||||
|
import NotificationSettings from '../notification-preferences/NotificationSettings';
|
||||||
import { withLocation, withNavigate } from './hoc';
|
import { withLocation, withNavigate } from './hoc';
|
||||||
|
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
|
||||||
|
|
||||||
class AccountSettingsPage extends React.Component {
|
class AccountSettingsPage extends React.Component {
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
@@ -65,6 +68,7 @@ class AccountSettingsPage extends React.Component {
|
|||||||
'#basic-information': React.createRef(),
|
'#basic-information': React.createRef(),
|
||||||
'#profile-information': React.createRef(),
|
'#profile-information': React.createRef(),
|
||||||
'#social-media': React.createRef(),
|
'#social-media': React.createRef(),
|
||||||
|
'#notifications': React.createRef(),
|
||||||
'#site-preferences': React.createRef(),
|
'#site-preferences': React.createRef(),
|
||||||
'#linked-accounts': React.createRef(),
|
'#linked-accounts': React.createRef(),
|
||||||
'#delete-account': React.createRef(),
|
'#delete-account': React.createRef(),
|
||||||
@@ -72,7 +76,7 @@ class AccountSettingsPage extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.fetchCourseList();
|
this.props.fetchNotificationPreferences();
|
||||||
this.props.fetchSettings();
|
this.props.fetchSettings();
|
||||||
this.props.fetchSiteLanguages(this.props.navigate);
|
this.props.fetchSiteLanguages(this.props.navigate);
|
||||||
sendTrackingLogEvent('edx.user.settings.viewed', {
|
sendTrackingLogEvent('edx.user.settings.viewed', {
|
||||||
@@ -120,7 +124,15 @@ class AccountSettingsPage extends React.Component {
|
|||||||
countryOptions: [{
|
countryOptions: [{
|
||||||
value: '',
|
value: '',
|
||||||
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
|
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
|
||||||
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
|
}].concat(
|
||||||
|
this.removeDisabledCountries(
|
||||||
|
getCountryList(locale).map(({ code, name }) => ({
|
||||||
|
value: code,
|
||||||
|
label: name,
|
||||||
|
disabled: this.isDisabledCountry(code),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
stateOptions: [{
|
stateOptions: [{
|
||||||
value: '',
|
value: '',
|
||||||
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
|
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
|
||||||
@@ -147,11 +159,30 @@ class AccountSettingsPage extends React.Component {
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
canDeleteAccount = () => {
|
||||||
|
const { committedValues } = this.props;
|
||||||
|
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
|
||||||
|
};
|
||||||
|
|
||||||
|
removeDisabledCountries = (countryList) => {
|
||||||
|
const { countriesCodesList, committedValues } = this.props;
|
||||||
|
const committedCountry = committedValues?.country;
|
||||||
|
|
||||||
|
if (!countriesCodesList.length) {
|
||||||
|
return countryList;
|
||||||
|
}
|
||||||
|
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
|
||||||
|
};
|
||||||
|
|
||||||
handleEditableFieldChange = (name, value) => {
|
handleEditableFieldChange = (name, value) => {
|
||||||
this.props.updateDraft(name, value);
|
this.props.updateDraft(name, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSubmit = (formId, values) => {
|
handleSubmit = (formId, values) => {
|
||||||
|
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { formValues } = this.props;
|
const { formValues } = this.props;
|
||||||
let extendedProfileObject = {};
|
let extendedProfileObject = {};
|
||||||
|
|
||||||
@@ -193,6 +224,12 @@ class AccountSettingsPage extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isDisabledCountry = (country) => {
|
||||||
|
const { countriesCodesList } = this.props;
|
||||||
|
|
||||||
|
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
|
||||||
|
};
|
||||||
|
|
||||||
isEditable(fieldName) {
|
isEditable(fieldName) {
|
||||||
return !this.props.staticFields.includes(fieldName);
|
return !this.props.staticFields.includes(fieldName);
|
||||||
}
|
}
|
||||||
@@ -466,7 +503,8 @@ class AccountSettingsPage extends React.Component {
|
|||||||
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
|
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
|
||||||
|
|
||||||
// Show State field only if the country is US (could include Canada later)
|
// Show State field only if the country is US (could include Canada later)
|
||||||
const showState = this.props.formValues.country === COUNTRY_WITH_STATES;
|
const { country } = this.props.formValues;
|
||||||
|
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
|
||||||
const { verifiedName } = this.props;
|
const { verifiedName } = this.props;
|
||||||
|
|
||||||
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
|
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
|
||||||
@@ -695,8 +733,10 @@ class AccountSettingsPage extends React.Component {
|
|||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
||||||
{...editableFieldProps}
|
{...editableFieldProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdditionalProfileFieldsSlot />
|
||||||
</div>
|
</div>
|
||||||
<div className="account-section pt-3 mb-5" id="social-media">
|
<div className="account-section pt-3 mb-6" id="social-media">
|
||||||
<h2 className="section-heading h4 mb-3">
|
<h2 className="section-heading h4 mb-3">
|
||||||
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
|
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -724,16 +764,19 @@ class AccountSettingsPage extends React.Component {
|
|||||||
{...editableFieldProps}
|
{...editableFieldProps}
|
||||||
/>
|
/>
|
||||||
<EditableField
|
<EditableField
|
||||||
name="social_link_twitter"
|
name="social_link_x"
|
||||||
type="text"
|
type="text"
|
||||||
value={this.props.formValues.social_link_twitter}
|
value={this.props.formValues.social_link_x}
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
|
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter'])}
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
|
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter.empty'])}
|
||||||
{...editableFieldProps}
|
{...editableFieldProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border border-light-700" />
|
||||||
<div className="account-section pt-3 mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
|
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
|
||||||
|
<NotificationSettings />
|
||||||
|
</div>
|
||||||
|
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
|
||||||
<h2 className="section-heading h4 mb-3">
|
<h2 className="section-heading h4 mb-3">
|
||||||
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -775,16 +818,15 @@ class AccountSettingsPage extends React.Component {
|
|||||||
<ThirdPartyAuth />
|
<ThirdPartyAuth />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{getConfig().ENABLE_ACCOUNT_DELETION
|
{getConfig().ENABLE_ACCOUNT_DELETION && (
|
||||||
&& (
|
|
||||||
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
|
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
|
||||||
<DeleteAccount
|
<DeleteAccount
|
||||||
isVerifiedAccount={this.props.isActive}
|
isVerifiedAccount={this.props.isActive}
|
||||||
hasLinkedTPA={hasLinkedTPA}
|
hasLinkedTPA={hasLinkedTPA}
|
||||||
|
canDeleteAccount={this.canDeleteAccount()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -813,24 +855,24 @@ class AccountSettingsPage extends React.Component {
|
|||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page__account-settings container-fluid py-5">
|
<Container className="page__account-settings py-5" size="xl">
|
||||||
{this.renderDuplicateTpaProviderMessage()}
|
{this.renderDuplicateTpaProviderMessage()}
|
||||||
<h1 className="mb-4">
|
<h1 className="mb-4">
|
||||||
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
|
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-2">
|
<div className="col-md-3">
|
||||||
<JumpNav />
|
<JumpNav />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-10">
|
<div className="col-md-9">
|
||||||
{loading ? this.renderLoading() : null}
|
{loading ? this.renderLoading() : null}
|
||||||
{loaded ? this.renderContent() : null}
|
{loaded ? this.renderContent() : null}
|
||||||
{loadingError ? this.renderError() : null}
|
{loadingError ? this.renderError() : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -849,18 +891,21 @@ AccountSettingsPage.propTypes = {
|
|||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
secondary_email: PropTypes.string,
|
secondary_email: PropTypes.string,
|
||||||
secondary_email_enabled: PropTypes.bool,
|
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||||
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
country: PropTypes.string,
|
country: PropTypes.string,
|
||||||
level_of_education: PropTypes.string,
|
level_of_education: PropTypes.string,
|
||||||
gender: PropTypes.string,
|
gender: PropTypes.string,
|
||||||
extended_profile: PropTypes.string,
|
extended_profile: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
field_name: PropTypes.string,
|
||||||
|
field_value: PropTypes.string,
|
||||||
|
})),
|
||||||
language_proficiencies: PropTypes.string,
|
language_proficiencies: PropTypes.string,
|
||||||
pending_name_change: PropTypes.string,
|
pending_name_change: PropTypes.string,
|
||||||
phone_number: PropTypes.string,
|
phone_number: PropTypes.string,
|
||||||
social_link_linkedin: PropTypes.string,
|
social_link_linkedin: PropTypes.string,
|
||||||
social_link_facebook: PropTypes.string,
|
social_link_facebook: PropTypes.string,
|
||||||
social_link_twitter: PropTypes.string,
|
social_link_x: PropTypes.string,
|
||||||
time_zone: PropTypes.string,
|
time_zone: PropTypes.string,
|
||||||
state: PropTypes.string,
|
state: PropTypes.string,
|
||||||
useVerifiedNameForCerts: PropTypes.bool.isRequired,
|
useVerifiedNameForCerts: PropTypes.bool.isRequired,
|
||||||
@@ -870,6 +915,7 @@ AccountSettingsPage.propTypes = {
|
|||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
useVerifiedNameForCerts: PropTypes.bool,
|
useVerifiedNameForCerts: PropTypes.bool,
|
||||||
verified_name: PropTypes.string,
|
verified_name: PropTypes.string,
|
||||||
|
country: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
drafts: PropTypes.shape({}),
|
drafts: PropTypes.shape({}),
|
||||||
formErrors: PropTypes.shape({
|
formErrors: PropTypes.shape({
|
||||||
@@ -902,13 +948,16 @@ AccountSettingsPage.propTypes = {
|
|||||||
saveSettings: PropTypes.func.isRequired,
|
saveSettings: PropTypes.func.isRequired,
|
||||||
fetchSettings: PropTypes.func.isRequired,
|
fetchSettings: PropTypes.func.isRequired,
|
||||||
beginNameChange: PropTypes.func.isRequired,
|
beginNameChange: PropTypes.func.isRequired,
|
||||||
fetchCourseList: PropTypes.func.isRequired,
|
fetchNotificationPreferences: PropTypes.func.isRequired,
|
||||||
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
|
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||||
connected: PropTypes.bool,
|
connected: PropTypes.bool,
|
||||||
})),
|
})),
|
||||||
nameChangeModal: PropTypes.shape({
|
nameChangeModal: PropTypes.oneOfType([
|
||||||
formId: PropTypes.string,
|
PropTypes.shape({
|
||||||
}),
|
formId: PropTypes.string,
|
||||||
|
}),
|
||||||
|
PropTypes.bool,
|
||||||
|
]),
|
||||||
verifiedName: PropTypes.shape({
|
verifiedName: PropTypes.shape({
|
||||||
verified_name: PropTypes.string,
|
verified_name: PropTypes.string,
|
||||||
status: PropTypes.string,
|
status: PropTypes.string,
|
||||||
@@ -928,6 +977,12 @@ AccountSettingsPage.propTypes = {
|
|||||||
),
|
),
|
||||||
navigate: PropTypes.func.isRequired,
|
navigate: PropTypes.func.isRequired,
|
||||||
location: PropTypes.string.isRequired,
|
location: PropTypes.string.isRequired,
|
||||||
|
countriesCodesList: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
AccountSettingsPage.defaultProps = {
|
AccountSettingsPage.defaultProps = {
|
||||||
@@ -937,6 +992,7 @@ AccountSettingsPage.defaultProps = {
|
|||||||
committedValues: {
|
committedValues: {
|
||||||
useVerifiedNameForCerts: false,
|
useVerifiedNameForCerts: false,
|
||||||
verified_name: null,
|
verified_name: null,
|
||||||
|
country: '',
|
||||||
},
|
},
|
||||||
drafts: {},
|
drafts: {},
|
||||||
formErrors: {},
|
formErrors: {},
|
||||||
@@ -949,14 +1005,15 @@ AccountSettingsPage.defaultProps = {
|
|||||||
tpaProviders: [],
|
tpaProviders: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
secondary_email_enabled: false,
|
secondary_email_enabled: false,
|
||||||
nameChangeModal: {},
|
nameChangeModal: {} || false,
|
||||||
verifiedName: null,
|
verifiedName: null,
|
||||||
mostRecentVerifiedName: {},
|
mostRecentVerifiedName: {},
|
||||||
verifiedNameHistory: [],
|
verifiedNameHistory: [],
|
||||||
|
countriesCodesList: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
||||||
fetchCourseList,
|
fetchNotificationPreferences,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
saveMultipleSettings,
|
saveMultipleSettings,
|
||||||
|
|||||||
@@ -509,15 +509,15 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Delete My Account',
|
defaultMessage: 'Delete My Account',
|
||||||
description: 'Header for the user account deletion area',
|
description: 'Header for the user account deletion area',
|
||||||
},
|
},
|
||||||
'account.settings.field.social.platform.name.twitter': {
|
'account.settings.field.social.platform.name.xTwitter': {
|
||||||
id: 'account.settings.field.social.platform.name.twitter',
|
id: 'account.settings.field.social.platform.name.xTwitter',
|
||||||
defaultMessage: 'Twitter',
|
defaultMessage: 'X (Twitter)',
|
||||||
description: 'Label for Twitter',
|
description: 'Label for X (Twitter)',
|
||||||
},
|
},
|
||||||
'account.settings.field.social.platform.name.twitter.empty': {
|
'account.settings.field.social.platform.name.xTwitter.empty': {
|
||||||
id: 'account.settings.field.social.platform.name.twitter.empty',
|
id: 'account.settings.field.social.platform.name.xTwitter.empty',
|
||||||
defaultMessage: 'Add Twitter profile',
|
defaultMessage: 'Add X profile',
|
||||||
description: 'Placeholder for an empty Twitter field',
|
description: 'Placeholder for an empty X field',
|
||||||
},
|
},
|
||||||
|
|
||||||
'account.settings.field.social.platform.name.facebook': {
|
'account.settings.field.social.platform.name.facebook': {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
|
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { connect, useDispatch } from 'react-redux';
|
import { connect, useDispatch } from 'react-redux';
|
||||||
import messages from './AccountSettingsPage.messages';
|
import messages from './AccountSettingsPage.messages';
|
||||||
import { YEAR_OF_BIRTH_OPTIONS } from './data/constants';
|
import { YEAR_OF_BIRTH_OPTIONS } from './data/constants';
|
||||||
@@ -11,11 +11,11 @@ import { editableFieldSelector } from './data/selectors';
|
|||||||
import { saveSettingsReset } from './data/actions';
|
import { saveSettingsReset } from './data/actions';
|
||||||
|
|
||||||
const DOBModal = (props) => {
|
const DOBModal = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
saveState,
|
saveState,
|
||||||
error,
|
error,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
intl,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -56,7 +56,7 @@ const DOBModal = (props) => {
|
|||||||
function renderErrors() {
|
function renderErrors() {
|
||||||
if (saveState === 'error' || error) {
|
if (saveState === 'error' || error) {
|
||||||
return (
|
return (
|
||||||
<Form.Control.Feedback type="invalid" key="general-error">
|
<Form.Control.Feedback type="invalid" key="general-error" data-testid="error-message">
|
||||||
{intl.formatMessage(messages['account.settingsfield.dob.error.general'])}
|
{intl.formatMessage(messages['account.settingsfield.dob.error.general'])}
|
||||||
</Form.Control.Feedback>
|
</Form.Control.Feedback>
|
||||||
);
|
);
|
||||||
@@ -72,7 +72,7 @@ const DOBModal = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="primary" onClick={open}>
|
<Button variant="primary" onClick={open} data-testid="open-modal-button">
|
||||||
{intl.formatMessage(messages['account.settings.field.dob.form.button'])}
|
{intl.formatMessage(messages['account.settings.field.dob.form.button'])}
|
||||||
</Button>
|
</Button>
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
@@ -81,25 +81,27 @@ const DOBModal = (props) => {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
hasCloseButton={false}
|
hasCloseButton={false}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
data-testid="dob-modal"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} data-testid="dob-form">
|
||||||
|
|
||||||
<ModalDialog.Header>
|
<ModalDialog.Header>
|
||||||
<ModalDialog.Title>
|
<ModalDialog.Title data-testid="modal-title">
|
||||||
{intl.formatMessage(messages['account.settings.field.dob.form.title'])}
|
{intl.formatMessage(messages['account.settings.field.dob.form.title'])}
|
||||||
</ModalDialog.Title>
|
</ModalDialog.Title>
|
||||||
</ModalDialog.Header>
|
</ModalDialog.Header>
|
||||||
|
|
||||||
<ModalDialog.Body className="overflow-hidden" style={{ padding: '1.5rem' }}>
|
<ModalDialog.Body className="overflow-hidden" style={{ padding: '1.5rem' }}>
|
||||||
<p>{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
|
<p data-testid="help-text">{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label>
|
<Form.Label data-testid="month-label">
|
||||||
{intl.formatMessage(messages['account.settings.field.dob.month'])}
|
{intl.formatMessage(messages['account.settings.field.dob.month'])}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="select"
|
as="select"
|
||||||
name="month"
|
name="month"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
data-testid="month-select"
|
||||||
>
|
>
|
||||||
<option value="">{intl.formatMessage(messages['account.settings.field.dob.month.default'])}</option>
|
<option value="">{intl.formatMessage(messages['account.settings.field.dob.month.default'])}</option>
|
||||||
{[...Array(12).keys()].map(month => (
|
{[...Array(12).keys()].map(month => (
|
||||||
@@ -108,13 +110,14 @@ const DOBModal = (props) => {
|
|||||||
</Form.Control>
|
</Form.Control>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label>
|
<Form.Label data-testid="year-label">
|
||||||
{intl.formatMessage(messages['account.settings.field.dob.year'])}
|
{intl.formatMessage(messages['account.settings.field.dob.year'])}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="select"
|
as="select"
|
||||||
name="year"
|
name="year"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
data-testid="year-select"
|
||||||
>
|
>
|
||||||
<option value="">{intl.formatMessage(messages['account.settings.field.dob.year.default'])}</option>
|
<option value="">{intl.formatMessage(messages['account.settings.field.dob.year.default'])}</option>
|
||||||
{YEAR_OF_BIRTH_OPTIONS.map(year => (
|
{YEAR_OF_BIRTH_OPTIONS.map(year => (
|
||||||
@@ -127,7 +130,7 @@ const DOBModal = (props) => {
|
|||||||
|
|
||||||
<ModalDialog.Footer>
|
<ModalDialog.Footer>
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<ModalDialog.CloseButton variant="tertiary">
|
<ModalDialog.CloseButton variant="tertiary" data-testid="cancel-button">
|
||||||
Cancel
|
Cancel
|
||||||
</ModalDialog.CloseButton>
|
</ModalDialog.CloseButton>
|
||||||
<StatefulButton
|
<StatefulButton
|
||||||
@@ -137,6 +140,7 @@ const DOBModal = (props) => {
|
|||||||
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||||
}}
|
}}
|
||||||
disabledStates={['unedited']}
|
disabledStates={['unedited']}
|
||||||
|
data-testid="submit-button"
|
||||||
/>
|
/>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
</ModalDialog.Footer>
|
</ModalDialog.Footer>
|
||||||
@@ -151,7 +155,6 @@ DOBModal.propTypes = {
|
|||||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||||
error: PropTypes.string,
|
error: PropTypes.string,
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: PropTypes.func.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
DOBModal.defaultProps = {
|
DOBModal.defaultProps = {
|
||||||
@@ -159,4 +162,4 @@ DOBModal.defaultProps = {
|
|||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(editableFieldSelector)(injectIntl(DOBModal));
|
export default connect(editableFieldSelector)(DOBModal);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Form, StatefulButton,
|
Button, Form, StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -39,10 +38,10 @@ const EditableField = (props) => {
|
|||||||
isEditing,
|
isEditing,
|
||||||
isEditable,
|
isEditable,
|
||||||
isGrayedOut,
|
isGrayedOut,
|
||||||
intl,
|
|
||||||
...others
|
...others
|
||||||
} = props;
|
} = props;
|
||||||
const id = `field-${name}`;
|
const id = `field-${name}`;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -85,9 +84,13 @@ const EditableField = (props) => {
|
|||||||
if (!confirmationMessageDefinition || !confirmationValue) {
|
if (!confirmationMessageDefinition || !confirmationValue) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return intl.formatMessage(confirmationMessageDefinition, {
|
return (
|
||||||
value: confirmationValue,
|
<span data-testid="editable-field-confirmation">
|
||||||
});
|
{intl.formatMessage(confirmationMessageDefinition, {
|
||||||
|
value: confirmationValue,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +99,7 @@ const EditableField = (props) => {
|
|||||||
cases={{
|
cases={{
|
||||||
editing: (
|
editing: (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} data-testid="editable-field-form">
|
||||||
<Form.Group
|
<Form.Group
|
||||||
controlId={id}
|
controlId={id}
|
||||||
isInvalid={error != null}
|
isInvalid={error != null}
|
||||||
@@ -109,10 +112,11 @@ const EditableField = (props) => {
|
|||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
data-testid="editable-field-textbox"
|
||||||
{...others}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||||
{error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
|
{error != null && <Form.Control.Feedback hasIcon={false} data-testid="editable-field-error">{error}</Form.Control.Feedback>}
|
||||||
{others.children}
|
{others.children}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<p>
|
<p>
|
||||||
@@ -134,16 +138,21 @@ const EditableField = (props) => {
|
|||||||
if (saveState === 'pending') { e.preventDefault(); }
|
if (saveState === 'pending') { e.preventDefault(); }
|
||||||
}}
|
}}
|
||||||
disabledStates={[]}
|
disabledStates={[]}
|
||||||
|
data-testid="editable-field-save"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
|
data-testid="editable-field-cancel"
|
||||||
|
data-clicked="cancel"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
|
{['name', 'verified_name'].includes(name) && (
|
||||||
|
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
default: (
|
default: (
|
||||||
@@ -151,7 +160,7 @@ const EditableField = (props) => {
|
|||||||
<div className="d-flex align-items-start">
|
<div className="d-flex align-items-start">
|
||||||
<h6 aria-level="3">{label}</h6>
|
<h6 aria-level="3">{label}</h6>
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
<Button variant="link" onClick={handleEdit} className="ml-3">
|
<Button variant="link" onClick={handleEdit} className="ml-3" data-testid="editable-field-edit" data-clicked="edit">
|
||||||
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -188,7 +197,6 @@ EditableField.propTypes = {
|
|||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
isEditable: PropTypes.bool,
|
isEditable: PropTypes.bool,
|
||||||
isGrayedOut: PropTypes.bool,
|
isGrayedOut: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EditableField.defaultProps = {
|
EditableField.defaultProps = {
|
||||||
@@ -209,4 +217,4 @@ EditableField.defaultProps = {
|
|||||||
export default connect(editableFieldSelector, {
|
export default connect(editableFieldSelector, {
|
||||||
onEdit: openForm,
|
onEdit: openForm,
|
||||||
onCancel: closeForm,
|
onCancel: closeForm,
|
||||||
})(injectIntl(EditableField));
|
})(EditableField);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Form, StatefulButton,
|
Button, Form, StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -19,6 +18,7 @@ import { editableFieldSelector } from './data/selectors';
|
|||||||
import CertificatePreference from './certificate-preference/CertificatePreference';
|
import CertificatePreference from './certificate-preference/CertificatePreference';
|
||||||
|
|
||||||
const EditableSelectField = (props) => {
|
const EditableSelectField = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
@@ -39,7 +39,6 @@ const EditableSelectField = (props) => {
|
|||||||
isEditing,
|
isEditing,
|
||||||
isEditable,
|
isEditable,
|
||||||
isGrayedOut,
|
isGrayedOut,
|
||||||
intl,
|
|
||||||
...others
|
...others
|
||||||
} = props;
|
} = props;
|
||||||
const id = `field-${name}`;
|
const id = `field-${name}`;
|
||||||
@@ -107,6 +106,7 @@ const EditableSelectField = (props) => {
|
|||||||
<option
|
<option
|
||||||
value={subOption.value}
|
value={subOption.value}
|
||||||
key={`${subOption.value}-${subOption.label}`}
|
key={`${subOption.value}-${subOption.label}`}
|
||||||
|
disabled={subOption?.disabled}
|
||||||
>
|
>
|
||||||
{subOption.label}
|
{subOption.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -115,7 +115,7 @@ const EditableSelectField = (props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<option value={option.value} key={`${option.value}-${option.label}`}>
|
<option value={option.value} key={`${option.value}-${option.label}`} disabled={option?.disabled}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
@@ -226,7 +226,6 @@ EditableSelectField.propTypes = {
|
|||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
isEditable: PropTypes.bool,
|
isEditable: PropTypes.bool,
|
||||||
isGrayedOut: PropTypes.bool,
|
isGrayedOut: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EditableSelectField.defaultProps = {
|
EditableSelectField.defaultProps = {
|
||||||
@@ -248,4 +247,4 @@ EditableSelectField.defaultProps = {
|
|||||||
export default connect(editableFieldSelector, {
|
export default connect(editableFieldSelector, {
|
||||||
onEdit: openForm,
|
onEdit: openForm,
|
||||||
onCancel: closeForm,
|
onCancel: closeForm,
|
||||||
})(injectIntl(EditableSelectField));
|
})(EditableSelectField);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, StatefulButton, Form,
|
Button, StatefulButton, Form, Tooltip, OverlayTrigger,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
@@ -35,9 +34,9 @@ const EmailField = (props) => {
|
|||||||
onChange,
|
onChange,
|
||||||
isEditing,
|
isEditing,
|
||||||
isEditable,
|
isEditable,
|
||||||
intl,
|
|
||||||
} = props;
|
} = props;
|
||||||
const id = `field-${name}`;
|
const id = `field-${name}`;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -162,7 +161,16 @@ const EmailField = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p data-hj-suppress>{renderValue()}</p>
|
<OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={(
|
||||||
|
<Tooltip id={`tooltip-${name}`} variant="light" className="d-sm-none">
|
||||||
|
{renderValue()}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p data-hj-suppress className="text-truncate">{renderValue()}</p>
|
||||||
|
</OverlayTrigger>
|
||||||
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
|
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -191,7 +199,6 @@ EmailField.propTypes = {
|
|||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
isEditable: PropTypes.bool,
|
isEditable: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EmailField.defaultProps = {
|
EmailField.defaultProps = {
|
||||||
@@ -210,4 +217,4 @@ EmailField.defaultProps = {
|
|||||||
export default connect(editableFieldSelector, {
|
export default connect(editableFieldSelector, {
|
||||||
onEdit: openForm,
|
onEdit: openForm,
|
||||||
onCancel: closeForm,
|
onCancel: closeForm,
|
||||||
})(injectIntl(EmailField));
|
})(EmailField);
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon';
|
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||||
import { OpenInNew } from '@openedx/paragon/icons';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { NavHashLink } from 'react-router-hash-link';
|
import { NavHashLink } from 'react-router-hash-link';
|
||||||
import Scrollspy from 'react-scrollspy';
|
import Scrollspy from 'react-scrollspy';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import messages from './AccountSettingsPage.messages';
|
import messages from './AccountSettingsPage.messages';
|
||||||
import { selectShowPreferences } from '../notification-preferences/data/selectors';
|
|
||||||
|
|
||||||
const JumpNav = ({
|
const JumpNav = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) => {
|
|
||||||
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
||||||
const showPreferences = useSelector(selectShowPreferences());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
|
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
|
||||||
<Scrollspy
|
<Scrollspy
|
||||||
items={[
|
items={[
|
||||||
'basic-information',
|
'basic-information',
|
||||||
'profile-information',
|
'profile-information',
|
||||||
'social-media',
|
'social-media',
|
||||||
|
'notifications',
|
||||||
'site-preferences',
|
'site-preferences',
|
||||||
'linked-accounts',
|
'linked-accounts',
|
||||||
'delete-account',
|
'delete-account',
|
||||||
]}
|
]}
|
||||||
className="list-unstyled"
|
className="list-unstyled"
|
||||||
currentClassName="font-weight-bold"
|
currentClassName="font-weight-bold"
|
||||||
|
offset={-64}
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<NavHashLink to="#basic-information">
|
<NavHashLink to="#basic-information">
|
||||||
@@ -46,6 +41,11 @@ const JumpNav = ({
|
|||||||
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||||
</NavHashLink>
|
</NavHashLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavHashLink to="#notifications">
|
||||||
|
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
|
||||||
|
</NavHashLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NavHashLink to="#site-preferences">
|
<NavHashLink to="#site-preferences">
|
||||||
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||||
@@ -65,27 +65,8 @@ const JumpNav = ({
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</Scrollspy>
|
</Scrollspy>
|
||||||
{showPreferences && (
|
|
||||||
<>
|
|
||||||
<hr />
|
|
||||||
<Scrollspy
|
|
||||||
className="list-unstyled"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
|
|
||||||
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</Scrollspy>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
JumpNav.propTypes = {
|
export default JumpNav;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(JumpNav);
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.custom-switch {
|
.custom-switch {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
@@ -44,3 +43,8 @@
|
|||||||
filter: alpha(opacity = 60); /* MSIE */
|
filter: alpha(opacity = 60); /* MSIE */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#tooltip-email .small {
|
||||||
|
display: block;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { connect, useDispatch } from 'react-redux';
|
import { connect, useDispatch } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ModalDialog,
|
ModalDialog,
|
||||||
StatefulButton,
|
StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeForm,
|
closeForm,
|
||||||
@@ -22,7 +22,6 @@ import commonMessages from '../AccountSettingsPage.messages';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CertificatePreference = ({
|
const CertificatePreference = ({
|
||||||
intl,
|
|
||||||
fieldName,
|
fieldName,
|
||||||
originalFullName,
|
originalFullName,
|
||||||
originalVerifiedName,
|
originalVerifiedName,
|
||||||
@@ -33,6 +32,7 @@ const CertificatePreference = ({
|
|||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const formId = 'useVerifiedNameForCerts';
|
const formId = 'useVerifiedNameForCerts';
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleCheckboxChange = () => {
|
const handleCheckboxChange = () => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
@@ -155,7 +155,6 @@ const CertificatePreference = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
CertificatePreference.propTypes = {
|
CertificatePreference.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
fieldName: PropTypes.string.isRequired,
|
fieldName: PropTypes.string.isRequired,
|
||||||
originalFullName: PropTypes.string,
|
originalFullName: PropTypes.string,
|
||||||
originalVerifiedName: PropTypes.string,
|
originalVerifiedName: PropTypes.string,
|
||||||
@@ -170,4 +169,4 @@ CertificatePreference.defaultProps = {
|
|||||||
useVerifiedNameForCerts: false,
|
useVerifiedNameForCerts: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));
|
export default connect(certPreferenceSelector)(CertificatePreference);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
|
import { postVerifiedNameConfig } from './service';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postVerifiedNameConfig', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts verified name config with useVerifiedNameForCerts = true', async () => {
|
||||||
|
const mockResponse = { data: { success: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postVerifiedNameConfig('testuser', { useVerifiedNameForCerts: true });
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
|
||||||
|
{
|
||||||
|
username: 'testuser',
|
||||||
|
use_verified_name_for_certs: true,
|
||||||
|
},
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts verified name config with useVerifiedNameForCerts = false', async () => {
|
||||||
|
const mockResponse = { data: { success: false } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postVerifiedNameConfig('anotheruser', { useVerifiedNameForCerts: false });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
|
||||||
|
{
|
||||||
|
username: 'anotheruser',
|
||||||
|
use_verified_name_for_certs: false,
|
||||||
|
},
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
postVerifiedNameConfig('erroruser', { useVerifiedNameForCerts: true }),
|
||||||
|
).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
import * as auth from '@edx/frontend-platform/auth';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import messages from '../messages';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
jest.mock('react-dom', () => ({
|
jest.mock('react-dom', () => ({
|
||||||
@@ -29,8 +30,6 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
||||||
|
|
||||||
const IntlCertificatePreference = injectIntl(CertificatePreference);
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('NameChange', () => {
|
describe('NameChange', () => {
|
||||||
@@ -38,7 +37,7 @@ describe('NameChange', () => {
|
|||||||
let store = {};
|
let store = {};
|
||||||
const formId = 'useVerifiedNameForCerts';
|
const formId = 'useVerifiedNameForCerts';
|
||||||
const updateDraft = 'UPDATE_DRAFT';
|
const updateDraft = 'UPDATE_DRAFT';
|
||||||
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
|
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage;
|
||||||
|
|
||||||
const reduxWrapper = children => (
|
const reduxWrapper = children => (
|
||||||
<Router>
|
<Router>
|
||||||
@@ -56,7 +55,6 @@ describe('NameChange', () => {
|
|||||||
originalVerifiedName: 'edX Verified',
|
originalVerifiedName: 'edX Verified',
|
||||||
saveState: null,
|
saveState: null,
|
||||||
useVerifiedNameForCerts: false,
|
useVerifiedNameForCerts: false,
|
||||||
intl: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||||
@@ -76,7 +74,7 @@ describe('NameChange', () => {
|
|||||||
originalVerifiedName: '',
|
originalVerifiedName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -87,7 +85,7 @@ describe('NameChange', () => {
|
|||||||
useVerifiedNameForCerts: true,
|
useVerifiedNameForCerts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(false);
|
expect(checkbox.checked).toEqual(false);
|
||||||
@@ -102,7 +100,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('triggers modal when attempting to uncheck checkbox', () => {
|
it('triggers modal when attempting to uncheck checkbox', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(true);
|
expect(checkbox.checked).toEqual(true);
|
||||||
@@ -114,7 +112,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates draft when changing radio value', () => {
|
it('updates draft when changing radio value', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -132,7 +130,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clears draft on cancel', () => {
|
it('clears draft on cancel', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -145,7 +143,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('submits', () => {
|
it('submits', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -165,7 +163,7 @@ describe('NameChange', () => {
|
|||||||
useVerifiedNameForCerts: true,
|
useVerifiedNameForCerts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(true);
|
expect(checkbox.checked).toEqual(true);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const fetchSettingsSuccess = ({
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
}) => ({
|
}) => ({
|
||||||
type: FETCH_SETTINGS.SUCCESS,
|
type: FETCH_SETTINGS.SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -35,6 +36,7 @@ export const fetchSettingsSuccess = ({
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,6 @@ export function getStatesList(country) {
|
|||||||
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DECLINED = 'declined';
|
export const FIELD_LABELS = {
|
||||||
export const SELF_DESCRIBE = 'self-describe';
|
COUNTRY: 'country',
|
||||||
export const OTHER = 'other';
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const defaultState = {
|
|||||||
verifiedName: null,
|
verifiedName: null,
|
||||||
mostRecentVerifiedName: {},
|
mostRecentVerifiedName: {},
|
||||||
verifiedNameHistory: {},
|
verifiedNameHistory: {},
|
||||||
|
countriesCodesList: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const reducer = (state = defaultState, action = {}) => {
|
const reducer = (state = defaultState, action = {}) => {
|
||||||
@@ -64,6 +65,7 @@ const reducer = (state = defaultState, action = {}) => {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
loadingError: null,
|
loadingError: null,
|
||||||
verifiedNameHistory: action.payload.verifiedNameHistory,
|
verifiedNameHistory: action.payload.verifiedNameHistory,
|
||||||
|
countriesCodesList: action.payload.countriesCodesList,
|
||||||
};
|
};
|
||||||
case FETCH_SETTINGS.FAILURE:
|
case FETCH_SETTINGS.FAILURE:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function* handleFetchSettings() {
|
|||||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
|
||||||
} = yield call(
|
} = yield call(
|
||||||
getSettings,
|
getSettings,
|
||||||
username,
|
username,
|
||||||
@@ -71,6 +71,7 @@ export function* handleFetchSettings() {
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList: countries,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
yield put(fetchSettingsFailure(e.message));
|
yield put(fetchSettingsFailure(e.message));
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ const previousSiteLanguageSelector = createSelector(
|
|||||||
accountSettings => accountSettings.previousSiteLanguage,
|
accountSettings => accountSettings.previousSiteLanguage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const countriesSelector = createSelector(
|
||||||
|
accountSettingsSelector,
|
||||||
|
accountSettings => accountSettings.countriesCodesList,
|
||||||
|
);
|
||||||
|
|
||||||
const editableFieldErrorSelector = createSelector(
|
const editableFieldErrorSelector = createSelector(
|
||||||
editableFieldNameSelector,
|
editableFieldNameSelector,
|
||||||
accountSettingsSelector,
|
accountSettingsSelector,
|
||||||
@@ -237,6 +242,7 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
mostRecentApprovedVerifiedNameValueSelector,
|
mostRecentApprovedVerifiedNameValueSelector,
|
||||||
mostRecentVerifiedNameSelector,
|
mostRecentVerifiedNameSelector,
|
||||||
sortedVerifiedNameHistorySelector,
|
sortedVerifiedNameHistorySelector,
|
||||||
|
countriesSelector,
|
||||||
(
|
(
|
||||||
accountSettings,
|
accountSettings,
|
||||||
siteLanguageOptions,
|
siteLanguageOptions,
|
||||||
@@ -254,6 +260,7 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
verifiedName,
|
verifiedName,
|
||||||
mostRecentVerifiedName,
|
mostRecentVerifiedName,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
) => ({
|
) => ({
|
||||||
siteLanguageOptions,
|
siteLanguageOptions,
|
||||||
siteLanguage,
|
siteLanguage,
|
||||||
@@ -274,6 +281,7 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
verifiedName,
|
verifiedName,
|
||||||
mostRecentVerifiedName,
|
mostRecentVerifiedName,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
import pick from 'lodash.pick';
|
import pick from 'lodash.pick';
|
||||||
import omit from 'lodash.omit';
|
import omit from 'lodash.omit';
|
||||||
import isEmpty from 'lodash.isempty';
|
import isEmpty from 'lodash.isempty';
|
||||||
@@ -7,9 +8,10 @@ import isEmpty from 'lodash.isempty';
|
|||||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||||
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
||||||
|
import { FIELD_LABELS } from './constants';
|
||||||
|
|
||||||
const SOCIAL_PLATFORMS = [
|
const SOCIAL_PLATFORMS = [
|
||||||
{ id: 'twitter', key: 'social_link_twitter' },
|
{ id: 'xTwitter', key: 'social_link_x' },
|
||||||
{ id: 'facebook', key: 'social_link_facebook' },
|
{ id: 'facebook', key: 'social_link_facebook' },
|
||||||
{ id: 'linkedin', key: 'social_link_linkedin' },
|
{ id: 'linkedin', key: 'social_link_linkedin' },
|
||||||
];
|
];
|
||||||
@@ -186,6 +188,24 @@ export async function postVerifiedName(data) {
|
|||||||
.catch(error => handleRequestError(error));
|
.catch(error => handleRequestError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCountryList(data) {
|
||||||
|
return data?.fields
|
||||||
|
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
|
||||||
|
?.options?.map(({ value }) => (value)) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountryList() {
|
||||||
|
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
|
return extractCountryList(data);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
|
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
|
||||||
* ThirdPartyAuth.
|
* ThirdPartyAuth.
|
||||||
@@ -197,12 +217,14 @@ export async function getSettings(username, userRoles) {
|
|||||||
thirdPartyAuthProviders,
|
thirdPartyAuthProviders,
|
||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
|
countries,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getAccount(username),
|
getAccount(username),
|
||||||
getPreferences(username),
|
getPreferences(username),
|
||||||
getThirdPartyAuthProviders(),
|
getThirdPartyAuthProviders(),
|
||||||
getProfileDataManager(username, userRoles),
|
getProfileDataManager(username, userRoles),
|
||||||
getTimeZones(),
|
getTimeZones(),
|
||||||
|
getCountryList(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -211,6 +233,7 @@ export async function getSettings(username, userRoles) {
|
|||||||
thirdPartyAuthProviders,
|
thirdPartyAuthProviders,
|
||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
|
countries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
181
src/account-settings/data/service.test.js
Normal file
181
src/account-settings/data/service.test.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
|
import { FIELD_LABELS } from './constants';
|
||||||
|
import {
|
||||||
|
getAccount,
|
||||||
|
patchAccount,
|
||||||
|
getPreferences,
|
||||||
|
patchPreferences,
|
||||||
|
getTimeZones,
|
||||||
|
getProfileDataManager,
|
||||||
|
getVerifiedName,
|
||||||
|
getVerifiedNameHistory,
|
||||||
|
postVerifiedName,
|
||||||
|
getCountryList,
|
||||||
|
patchSettings,
|
||||||
|
} from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('@edx/frontend-platform/logging');
|
||||||
|
|
||||||
|
const mockHttpClient = {
|
||||||
|
get: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||||
|
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://lms.test' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('account service', () => {
|
||||||
|
describe('getAccount', () => {
|
||||||
|
it('returns unpacked account data', async () => {
|
||||||
|
const apiResponse = {
|
||||||
|
username: 'testuser',
|
||||||
|
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
|
||||||
|
language_proficiencies: [{ code: 'en' }],
|
||||||
|
};
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
|
||||||
|
|
||||||
|
const result = await getAccount('testuser');
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith('http://lms.test/api/user/v1/accounts/testuser');
|
||||||
|
expect(result.social_link_x).toEqual('http://t');
|
||||||
|
expect(result.language_proficiencies).toEqual('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchAccount', () => {
|
||||||
|
it('sends packed commit data and returns unpacked response', async () => {
|
||||||
|
const commit = { social_link_x: 'http://t' };
|
||||||
|
const apiResponse = {
|
||||||
|
username: 'testuser',
|
||||||
|
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
|
||||||
|
language_proficiencies: [],
|
||||||
|
};
|
||||||
|
mockHttpClient.patch.mockResolvedValue({ data: apiResponse });
|
||||||
|
|
||||||
|
const result = await patchAccount('testuser', commit);
|
||||||
|
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/api/user/v1/accounts/testuser',
|
||||||
|
expect.objectContaining({ social_links: [{ platform: 'xTwitter', social_link: 'http://t' }] }),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(result.social_link_x).toEqual('http://t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPreferences', () => {
|
||||||
|
it('returns preferences data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { theme: 'dark' } });
|
||||||
|
const result = await getPreferences('user');
|
||||||
|
expect(result.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchPreferences', () => {
|
||||||
|
it('patches preferences and returns commitValues', async () => {
|
||||||
|
mockHttpClient.patch.mockResolvedValue({});
|
||||||
|
const commit = { time_zone: 'UTC' };
|
||||||
|
const result = await patchPreferences('user', commit);
|
||||||
|
expect(mockHttpClient.patch).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(commit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTimeZones', () => {
|
||||||
|
it('returns data from API', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: ['UTC', 'PST'] });
|
||||||
|
const result = await getTimeZones('PK');
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/user_api/v1/preferences/time_zones/',
|
||||||
|
{ params: { country_code: 'PK' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(['UTC', 'PST']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProfileDataManager', () => {
|
||||||
|
it('returns null if no enterprise manages profile', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { results: [] } });
|
||||||
|
const result = await getProfileDataManager('user', ['learner']);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns enterprise name if sync is enabled', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { results: [{ enterprise_customer: { name: 'Acme', sync_learner_profile_data: true } }] } });
|
||||||
|
const result = await getProfileDataManager('user', ['enterprise_learner']);
|
||||||
|
expect(result).toBe('Acme');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerifiedName', () => {
|
||||||
|
it('returns verified name data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { verified: true } });
|
||||||
|
const result = await getVerifiedName();
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns {} on error', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||||
|
const result = await getVerifiedName();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerifiedNameHistory', () => {
|
||||||
|
it('returns verified name history data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: [{ id: 1 }] });
|
||||||
|
const result = await getVerifiedNameHistory();
|
||||||
|
expect(result[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postVerifiedName', () => {
|
||||||
|
it('posts verified name data', async () => {
|
||||||
|
mockHttpClient.post.mockResolvedValue({});
|
||||||
|
await postVerifiedName({ first_name: 'A' });
|
||||||
|
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/api/edx_name_affirmation/v1/verified_name',
|
||||||
|
{ first_name: 'A' },
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCountryList', () => {
|
||||||
|
it('extracts country values from registration API', async () => {
|
||||||
|
const apiResponse = { fields: [{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'PK' }] }] };
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
|
||||||
|
const result = await getCountryList();
|
||||||
|
expect(result).toEqual(['PK']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] and logs error on failure', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||||
|
const result = await getCountryList();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(logError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchSettings', () => {
|
||||||
|
it('calls patchAccount and patchPreferences as needed', async () => {
|
||||||
|
mockHttpClient.patch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
username: 'user',
|
||||||
|
social_links: [],
|
||||||
|
language_proficiencies: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await patchSettings('user', { time_zone: 'UTC', social_link_twitter: 't' });
|
||||||
|
expect(result.username).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Hyperlink } from '@openedx/paragon';
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
@@ -13,7 +12,8 @@ import messages from './messages';
|
|||||||
import Alert from '../Alert';
|
import Alert from '../Alert';
|
||||||
|
|
||||||
const BeforeProceedingBanner = (props) => {
|
const BeforeProceedingBanner = (props) => {
|
||||||
const { instructionMessageId, intl, supportArticleUrl } = props;
|
const { instructionMessageId, supportArticleUrl } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -41,8 +41,7 @@ const BeforeProceedingBanner = (props) => {
|
|||||||
|
|
||||||
BeforeProceedingBanner.propTypes = {
|
BeforeProceedingBanner.propTypes = {
|
||||||
instructionMessageId: PropTypes.string.isRequired,
|
instructionMessageId: PropTypes.string.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
supportArticleUrl: PropTypes.string.isRequired,
|
supportArticleUrl: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(BeforeProceedingBanner);
|
export default BeforeProceedingBanner;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
jest.mock('react-dom', () => ({
|
jest.mock('react-dom', () => ({
|
||||||
...jest.requireActual('react-dom'),
|
...jest.requireActual('react-dom'),
|
||||||
@@ -9,19 +8,16 @@ jest.mock('react-dom', () => ({
|
|||||||
|
|
||||||
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
|
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
|
||||||
|
|
||||||
const IntlBeforeProceedingBanner = injectIntl(BeforeProceedingBanner);
|
|
||||||
|
|
||||||
describe('BeforeProceedingBanner', () => {
|
describe('BeforeProceedingBanner', () => {
|
||||||
it('should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link', () => {
|
it('should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link', () => {
|
||||||
const props = {
|
const props = {
|
||||||
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
||||||
intl: createIntl({ locale: 'en' }),
|
|
||||||
supportArticleUrl: '',
|
supportArticleUrl: '',
|
||||||
};
|
};
|
||||||
const tree = renderer
|
const tree = renderer
|
||||||
.create((
|
.create((
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlBeforeProceedingBanner
|
<BeforeProceedingBanner
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
@@ -33,13 +29,12 @@ describe('BeforeProceedingBanner', () => {
|
|||||||
it('should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link', () => {
|
it('should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link', () => {
|
||||||
const props = {
|
const props = {
|
||||||
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
||||||
intl: createIntl({ locale: 'en' }),
|
|
||||||
supportArticleUrl: 'http://test-support.edx',
|
supportArticleUrl: 'http://test-support.edx',
|
||||||
};
|
};
|
||||||
const tree = renderer
|
const tree = renderer
|
||||||
.create((
|
.create((
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlBeforeProceedingBanner
|
<BeforeProceedingBanner
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertModal,
|
AlertModal,
|
||||||
Button, Input, ValidationFormGroup, ActionRow,
|
Button, Form, ActionRow,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
@@ -78,10 +78,11 @@ export class ConfirmationModal extends Component {
|
|||||||
isOpen={open}
|
isOpen={open}
|
||||||
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
|
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
|
isOverflowVisible
|
||||||
footerNode={(
|
footerNode={(
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<Button variant="link" onClick={onCancel}>Cancel</Button>
|
<Button variant="link" onClick={onCancel}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}</Button>
|
||||||
<Button variant="danger" onClick={onSubmit}>Yes, Delete</Button>
|
<Button variant="danger" onClick={onSubmit}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}</Button>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -107,22 +108,26 @@ export class ConfirmationModal extends Component {
|
|||||||
<PrintingInstructions />
|
<PrintingInstructions />
|
||||||
</p>
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
<ValidationFormGroup
|
<Form.Group
|
||||||
for={passwordFieldId}
|
for={passwordFieldId}
|
||||||
invalid={errorType !== null}
|
isInvalid={errorType !== null}
|
||||||
invalidMessage={intl.formatMessage(invalidMessage)}
|
|
||||||
>
|
>
|
||||||
<label className="d-block" htmlFor={passwordFieldId}>
|
<Form.Label className="d-block" htmlFor={passwordFieldId}>
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
|
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
|
||||||
</label>
|
</Form.Label>
|
||||||
<Input
|
<Form.Control
|
||||||
name="password"
|
name="password"
|
||||||
id={passwordFieldId}
|
id={passwordFieldId}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</ValidationFormGroup>
|
{errorType !== null && (
|
||||||
|
<Form.Control.Feedback type="invalid" feedback-for={passwordFieldId}>
|
||||||
|
{intl.formatMessage(invalidMessage)}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
)}
|
||||||
|
</Form.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
|
|||||||
@@ -76,67 +76,74 @@ export class DeleteAccount extends React.Component {
|
|||||||
<h2 className="section-heading h4 mb-3">
|
<h2 className="section-heading h4 mb-3">
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
||||||
</h2>
|
</h2>
|
||||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
{
|
||||||
<p>
|
this.props.canDeleteAccount ? (
|
||||||
{intl.formatMessage(
|
<>
|
||||||
messages['account.settings.delete.account.text.1'],
|
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p>
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages['account.settings.delete.account.text.1'],
|
||||||
<p>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
{intl.formatMessage(
|
)}
|
||||||
messages[deleteAccountText2MessageKey],
|
</p>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p>
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages[deleteAccountText2MessageKey],
|
||||||
<p>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
<PrintingInstructions />
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-danger h6">
|
<p>
|
||||||
{intl.formatMessage(
|
<PrintingInstructions />
|
||||||
messages['account.settings.delete.account.text.warning'],
|
</p>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p className="text-danger h6">
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages['account.settings.delete.account.text.warning'],
|
||||||
<p>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
)}
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
</p>
|
||||||
</Hyperlink>
|
<p>
|
||||||
</p>
|
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||||
<p>
|
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||||
<Button
|
</Hyperlink>
|
||||||
variant="outline-danger"
|
</p>
|
||||||
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
<p>
|
||||||
disabled={!canDelete}
|
<Button
|
||||||
>
|
variant="outline-danger"
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
||||||
</Button>
|
disabled={!canDelete}
|
||||||
</p>
|
>
|
||||||
|
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
{isVerifiedAccount ? null : (
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
instructionMessageId={optInInstructionMessageId}
|
||||||
|
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasLinkedTPA ? (
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
instructionMessageId="account.settings.delete.account.please.unlink"
|
||||||
|
supportArticleUrl={supportArticleUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isVerifiedAccount ? null : (
|
<ConnectedConfirmationModal
|
||||||
<BeforeProceedingBanner
|
status={status}
|
||||||
instructionMessageId={optInInstructionMessageId}
|
errorType={errorType}
|
||||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
onSubmit={this.handleSubmit}
|
||||||
/>
|
onCancel={this.handleCancel}
|
||||||
)}
|
onChange={this.handlePasswordChange}
|
||||||
|
password={this.state.password}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasLinkedTPA ? (
|
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
||||||
<BeforeProceedingBanner
|
</>
|
||||||
instructionMessageId="account.settings.delete.account.please.unlink"
|
) : (
|
||||||
supportArticleUrl={supportArticleUrl}
|
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
|
||||||
/>
|
)
|
||||||
) : null}
|
}
|
||||||
|
|
||||||
<ConnectedConfirmationModal
|
|
||||||
status={status}
|
|
||||||
errorType={errorType}
|
|
||||||
onSubmit={this.handleSubmit}
|
|
||||||
onCancel={this.handleCancel}
|
|
||||||
onChange={this.handlePasswordChange}
|
|
||||||
password={this.state.password}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,6 +159,7 @@ DeleteAccount.propTypes = {
|
|||||||
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
||||||
hasLinkedTPA: PropTypes.bool,
|
hasLinkedTPA: PropTypes.bool,
|
||||||
isVerifiedAccount: PropTypes.bool,
|
isVerifiedAccount: PropTypes.bool,
|
||||||
|
canDeleteAccount: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +168,7 @@ DeleteAccount.defaultProps = {
|
|||||||
isVerifiedAccount: true,
|
isVerifiedAccount: true,
|
||||||
status: null,
|
status: null,
|
||||||
errorType: null,
|
errorType: null,
|
||||||
|
canDeleteAccount: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assume we're part of the accountSettings state.
|
// Assume we're part of the accountSettings state.
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import React from 'react';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink } from '@openedx/paragon';
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const PrintingInstructions = (props) => {
|
const PrintingInstructions = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const actionLink = (
|
const actionLink = (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
// TODO: What would a generic version of this link look like? Should
|
// TODO: What would a generic version of this link look like? Should
|
||||||
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
||||||
// We've removed the link from the default message.
|
// We've removed the link from the default message.
|
||||||
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
|
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
|
{intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -40,8 +40,4 @@ const PrintingInstructions = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PrintingInstructions.propTypes = {
|
export default PrintingInstructions;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PrintingInstructions);
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
|
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
export const SuccessModal = (props) => {
|
export const SuccessModal = (props) => {
|
||||||
const { status, intl, onClose } = props;
|
const intl = useIntl();
|
||||||
|
const { status, onClose } = props;
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
|
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
|
||||||
@@ -20,7 +20,7 @@ export const SuccessModal = (props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<ModalCloseButton className="float-right" variant="link">Close</ModalCloseButton>
|
<ModalCloseButton className="float-right" variant="link">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ export const SuccessModal = (props) => {
|
|||||||
|
|
||||||
SuccessModal.propTypes = {
|
SuccessModal.propTypes = {
|
||||||
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
|
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
|
||||||
intl: intlShape.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,4 +38,4 @@ SuccessModal.defaultProps = {
|
|||||||
status: null,
|
status: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(SuccessModal);
|
export default SuccessModal;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { waitFor } from '@testing-library/react';
|
import { waitFor } from '@testing-library/react';
|
||||||
import { SuccessModal } from './SuccessModal';
|
import { SuccessModal } from './SuccessModal';
|
||||||
|
|
||||||
@@ -10,8 +9,6 @@ jest.mock('react-dom', () => ({
|
|||||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlSuccessModal = injectIntl(SuccessModal);
|
|
||||||
|
|
||||||
describe('SuccessModal', () => {
|
describe('SuccessModal', () => {
|
||||||
let props = {};
|
let props = {};
|
||||||
|
|
||||||
@@ -25,22 +22,22 @@ describe('SuccessModal', () => {
|
|||||||
it('should match default closed success modal snapshot', async () => {
|
it('should match default closed success modal snapshot', async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const tree = renderer.create((
|
const tree = renderer.create((
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>)).toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const tree = renderer.create((
|
const tree = renderer.create((
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const tree = renderer.create((
|
const tree = renderer.create((
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const tree = renderer.create((
|
const tree = renderer.create((
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +46,7 @@ describe('SuccessModal', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlSuccessModal
|
<SuccessModal
|
||||||
{...props}
|
{...props}
|
||||||
status="deleted"
|
status="deleted"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
|
|||||||
data-testid="modal-backdrop"
|
data-testid="modal-backdrop"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
onKeyDown={[MockFunction]}
|
onKeyDown={[MockFunction]}
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
aria-label="Are you sure?"
|
aria-label="Are you sure?"
|
||||||
@@ -131,30 +132,57 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="form-group"
|
className="pgn__form-group"
|
||||||
data-testid="validation-form-group"
|
for="passwordFieldId"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
className="d-block"
|
className="pgn__form-label d-block"
|
||||||
htmlFor="passwordFieldId"
|
htmlFor="form-field3"
|
||||||
>
|
>
|
||||||
If you still wish to continue and delete your account, please enter your account password:
|
If you still wish to continue and delete your account, please enter your account password:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div
|
||||||
aria-describedby="passwordFieldId-invalid-feedback"
|
className="pgn__form-control-decorator-group"
|
||||||
className="form-control is-invalid"
|
|
||||||
id="passwordFieldId"
|
|
||||||
name="password"
|
|
||||||
onChange={[MockFunction]}
|
|
||||||
type="password"
|
|
||||||
value="fluffy bunnies"
|
|
||||||
/>
|
|
||||||
<strong
|
|
||||||
className="invalid-feedback"
|
|
||||||
id="passwordFieldId-invalid-feedback"
|
|
||||||
>
|
>
|
||||||
A password is required
|
<input
|
||||||
</strong>
|
aria-describedby="form-field3-5"
|
||||||
|
className="has-value form-control is-invalid"
|
||||||
|
id="form-field3"
|
||||||
|
name="password"
|
||||||
|
onBlur={[Function]}
|
||||||
|
onChange={[Function]}
|
||||||
|
type="password"
|
||||||
|
value="fluffy bunnies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
|
||||||
|
feedback-for="passwordFieldId"
|
||||||
|
id="form-field3-5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pgn__icon"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden={true}
|
||||||
|
fill="none"
|
||||||
|
focusable={false}
|
||||||
|
height={24}
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={24}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
A password is required
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,15 +248,17 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
|||||||
"width": "1px",
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
tabIndex={0}
|
||||||
/>,
|
/>,
|
||||||
<div
|
<div
|
||||||
className="pgn__modal-layer"
|
className="pgn__modal-layer"
|
||||||
data-focus-lock-disabled="disabled"
|
data-focus-lock-disabled={false}
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
|
onMouseDown={[Function]}
|
||||||
onScrollCapture={[Function]}
|
onScrollCapture={[Function]}
|
||||||
onTouchMoveCapture={[Function]}
|
onTouchMoveCapture={[Function]}
|
||||||
|
onTouchStart={[Function]}
|
||||||
onWheelCapture={[Function]}
|
onWheelCapture={[Function]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -239,6 +269,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
|||||||
data-testid="modal-backdrop"
|
data-testid="modal-backdrop"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
onKeyDown={[MockFunction]}
|
onKeyDown={[MockFunction]}
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
aria-label="Are you sure?"
|
aria-label="Are you sure?"
|
||||||
@@ -299,30 +330,28 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="form-group"
|
className="pgn__form-group"
|
||||||
data-testid="validation-form-group"
|
for="passwordFieldId"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
className="d-block"
|
className="pgn__form-label d-block"
|
||||||
htmlFor="passwordFieldId"
|
htmlFor="form-field1"
|
||||||
>
|
>
|
||||||
If you still wish to continue and delete your account, please enter your account password:
|
If you still wish to continue and delete your account, please enter your account password:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div
|
||||||
aria-describedby=""
|
className="pgn__form-control-decorator-group"
|
||||||
className="form-control"
|
|
||||||
id="passwordFieldId"
|
|
||||||
name="password"
|
|
||||||
onChange={[MockFunction]}
|
|
||||||
type="password"
|
|
||||||
value="fluffy bunnies"
|
|
||||||
/>
|
|
||||||
<strong
|
|
||||||
className="invalid-feedback"
|
|
||||||
id="passwordFieldId-invalid-feedback"
|
|
||||||
>
|
>
|
||||||
Unable to delete account
|
<input
|
||||||
</strong>
|
className="has-value form-control"
|
||||||
|
id="form-field1"
|
||||||
|
name="password"
|
||||||
|
onBlur={[Function]}
|
||||||
|
onChange={[Function]}
|
||||||
|
type="password"
|
||||||
|
value="fluffy bunnies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +397,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
|||||||
"width": "1px",
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
tabIndex={0}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
|
|||||||
"width": "1px",
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
tabIndex={0}
|
||||||
/>,
|
/>,
|
||||||
<div
|
<div
|
||||||
className="pgn__modal-layer"
|
className="pgn__modal-layer"
|
||||||
data-focus-lock-disabled="disabled"
|
data-focus-lock-disabled={false}
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
|
onMouseDown={[Function]}
|
||||||
onScrollCapture={[Function]}
|
onScrollCapture={[Function]}
|
||||||
onTouchMoveCapture={[Function]}
|
onTouchMoveCapture={[Function]}
|
||||||
|
onTouchStart={[Function]}
|
||||||
onWheelCapture={[Function]}
|
onWheelCapture={[Function]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -42,6 +44,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
|
|||||||
data-testid="modal-backdrop"
|
data-testid="modal-backdrop"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
onKeyDown={[MockFunction]}
|
onKeyDown={[MockFunction]}
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="mw-sm p-5 bg-white mx-auto my-3"
|
className="mw-sm p-5 bg-white mx-auto my-3"
|
||||||
@@ -84,7 +87,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
|
|||||||
"width": "1px",
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
tabIndex={0}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|||||||
65
src/account-settings/delete-account/data/service.test.js
Normal file
65
src/account-settings/delete-account/data/service.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import formurlencoded from 'form-urlencoded';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postDeleteAccount } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('form-urlencoded');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postDeleteAccount', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts delete account request with password', async () => {
|
||||||
|
const mockResponse = { data: { success: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postDeleteAccount('mypassword');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(formurlencoded).toHaveBeenCalledWith({ password: 'mypassword' });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/user/v1/accounts/deactivate_logout/',
|
||||||
|
'encoded:{"password":"mypassword"}',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postDeleteAccount('wrongpassword')).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
'account.settings.cannot.delete.account.text': {
|
||||||
|
id: 'account.settings.cannot.delete.account.text',
|
||||||
|
defaultMessage: 'Please note that, for legal and regulatory compliance purposes, account deletion is currently unavailable.',
|
||||||
|
description: 'This text is visible when user is not allowed to delete account',
|
||||||
|
},
|
||||||
'account.settings.delete.account.header': {
|
'account.settings.delete.account.header': {
|
||||||
id: 'account.settings.delete.account.header',
|
id: 'account.settings.delete.account.header',
|
||||||
defaultMessage: 'Delete My Account',
|
defaultMessage: 'Delete My Account',
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { connect, useDispatch } from 'react-redux';
|
import { connect, useDispatch } from 'react-redux';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -25,7 +25,6 @@ const NameChangeModal = ({
|
|||||||
targetFormId,
|
targetFormId,
|
||||||
errors,
|
errors,
|
||||||
formValues,
|
formValues,
|
||||||
intl,
|
|
||||||
saveState,
|
saveState,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -33,6 +32,7 @@ const NameChangeModal = ({
|
|||||||
const { username } = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
|
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
|
||||||
const [confirmedWarning, setConfirmedWarning] = useState(false);
|
const [confirmedWarning, setConfirmedWarning] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const resetLocalState = useCallback(() => {
|
const resetLocalState = useCallback(() => {
|
||||||
setConfirmedWarning(false);
|
setConfirmedWarning(false);
|
||||||
@@ -193,11 +193,10 @@ NameChangeModal.propTypes = {
|
|||||||
verified_name: PropTypes.string,
|
verified_name: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
saveState: PropTypes.string,
|
saveState: PropTypes.string,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
NameChangeModal.defaultProps = {
|
NameChangeModal.defaultProps = {
|
||||||
saveState: null,
|
saveState: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));
|
export default connect(nameChangeSelector)(NameChangeModal);
|
||||||
|
|||||||
56
src/account-settings/name-change/data/service.test.js
Normal file
56
src/account-settings/name-change/data/service.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postNameChange } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postNameChange', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts a name change request successfully', async () => {
|
||||||
|
const mockResponse = { data: { success: true, updated: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postNameChange('New Name');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/user/v1/accounts/name_change/',
|
||||||
|
{ name: 'New Name' },
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postNameChange('Bad Name')).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
/* eslint-disable no-import-assign */
|
/* eslint-disable no-import-assign */
|
||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
import * as auth from '@edx/frontend-platform/auth';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
jest.mock('react-dom', () => ({
|
jest.mock('react-dom', () => ({
|
||||||
@@ -29,8 +28,6 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
|
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
|
||||||
|
|
||||||
const IntlNameChange = injectIntl(NameChange);
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('NameChange', () => {
|
describe('NameChange', () => {
|
||||||
@@ -55,7 +52,6 @@ describe('NameChange', () => {
|
|||||||
verified_name: 'edX Verified',
|
verified_name: 'edX Verified',
|
||||||
},
|
},
|
||||||
saveState: null,
|
saveState: null,
|
||||||
intl: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||||
@@ -72,7 +68,7 @@ describe('NameChange', () => {
|
|||||||
it('renders populated input after clicking continue if verified_name in form data', async () => {
|
it('renders populated input after clicking continue if verified_name in form data', async () => {
|
||||||
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
expect(getInput()).toBeNull();
|
expect(getInput()).toBeNull();
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
@@ -89,7 +85,7 @@ describe('NameChange', () => {
|
|||||||
name: 'edx edx',
|
name: 'edx edx',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render(reduxWrapper(<IntlNameChange {...formProps} />));
|
render(reduxWrapper(<NameChange {...formProps} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
@@ -107,7 +103,7 @@ describe('NameChange', () => {
|
|||||||
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
@@ -134,7 +130,7 @@ describe('NameChange', () => {
|
|||||||
targetFormId: 'name',
|
targetFormId: 'name',
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...formProps} />));
|
render(reduxWrapper(<NameChange {...formProps} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
@@ -150,7 +146,7 @@ describe('NameChange', () => {
|
|||||||
it('does not dispatch action while pending', async () => {
|
it('does not dispatch action while pending', async () => {
|
||||||
props.saveState = 'pending';
|
props.saveState = 'pending';
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
@@ -166,7 +162,7 @@ describe('NameChange', () => {
|
|||||||
it('routes to IDV when name change request is successful', async () => {
|
it('routes to IDV when name change request is successful', async () => {
|
||||||
props.saveState = 'complete';
|
props.saveState = 'complete';
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
expect(window.location.pathname).toEqual('/id-verification');
|
expect(window.location.pathname).toEqual('/id-verification');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { StatefulButton } from '@openedx/paragon';
|
import { StatefulButton } from '@openedx/paragon';
|
||||||
|
|
||||||
import { resetPassword } from './data/actions';
|
import { resetPassword } from './data/actions';
|
||||||
@@ -10,7 +9,9 @@ import ConfirmationAlert from './ConfirmationAlert';
|
|||||||
import RequestInProgressAlert from './RequestInProgressAlert';
|
import RequestInProgressAlert from './RequestInProgressAlert';
|
||||||
|
|
||||||
const ResetPassword = (props) => {
|
const ResetPassword = (props) => {
|
||||||
const { email, intl, status } = props;
|
const { email, status } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
@@ -51,7 +52,6 @@ const ResetPassword = (props) => {
|
|||||||
|
|
||||||
ResetPassword.propTypes = {
|
ResetPassword.propTypes = {
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
resetPassword: PropTypes.func.isRequired,
|
resetPassword: PropTypes.func.isRequired,
|
||||||
status: PropTypes.string,
|
status: PropTypes.string,
|
||||||
};
|
};
|
||||||
@@ -68,4 +68,4 @@ export default connect(
|
|||||||
{
|
{
|
||||||
resetPassword,
|
resetPassword,
|
||||||
},
|
},
|
||||||
)(injectIntl(ResetPassword));
|
)(ResetPassword);
|
||||||
|
|||||||
65
src/account-settings/reset-password/data/service.test.js
Normal file
65
src/account-settings/reset-password/data/service.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import formurlencoded from 'form-urlencoded';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postResetPassword } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('form-urlencoded');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postResetPassword', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts reset password request with email', async () => {
|
||||||
|
const mockResponse = { data: { success: true, email_sent: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postResetPassword('user@example.com');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(formurlencoded).toHaveBeenCalledWith({ email: 'user@example.com' });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/password_reset/',
|
||||||
|
'encoded:{"email":"user@example.com"}',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Reset password failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postResetPassword('bad@example.com')).rejects.toThrow('Reset password failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/account-settings/site-language/service.test.js
Normal file
95
src/account-settings/site-language/service.test.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { convertKeyNames, snakeCaseObject } from '@edx/frontend-platform/utils';
|
||||||
|
|
||||||
|
import { getSiteLanguageList, patchPreferences, postSetLang } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('@edx/frontend-platform/utils');
|
||||||
|
jest.mock('./constants', () => (['en', 'es', 'fr']));
|
||||||
|
|
||||||
|
describe('preferencesApi', () => {
|
||||||
|
const mockPatch = jest.fn();
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
patch: mockPatch,
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
snakeCaseObject.mockImplementation(obj => obj);
|
||||||
|
convertKeyNames.mockImplementation((obj) => obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSiteLanguageList', () => {
|
||||||
|
it('returns the siteLanguageList constant', async () => {
|
||||||
|
const result = await getSiteLanguageList();
|
||||||
|
expect(result).toEqual(['en', 'es', 'fr']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchPreferences', () => {
|
||||||
|
it('patches preferences with processed params and returns the original params', async () => {
|
||||||
|
const username = 'testuser';
|
||||||
|
const params = { prefLang: 'en', darkMode: true };
|
||||||
|
const processed = { 'pref-lang': 'en', dark_mode: true };
|
||||||
|
|
||||||
|
// Mock conversions
|
||||||
|
snakeCaseObject.mockReturnValueOnce({ pref_lang: 'en', dark_mode: true });
|
||||||
|
convertKeyNames.mockReturnValueOnce(processed);
|
||||||
|
|
||||||
|
mockPatch.mockResolvedValueOnce({ data: { success: true } });
|
||||||
|
|
||||||
|
const result = await patchPreferences(username, params);
|
||||||
|
|
||||||
|
expect(snakeCaseObject).toHaveBeenCalledWith(params);
|
||||||
|
expect(convertKeyNames).toHaveBeenCalledWith(
|
||||||
|
{ pref_lang: 'en', dark_mode: true },
|
||||||
|
{ pref_lang: 'pref-lang' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/user/v1/preferences/testuser',
|
||||||
|
processed,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(params);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postSetLang', () => {
|
||||||
|
it('posts language selection via FormData', async () => {
|
||||||
|
const mockResponse = { data: { success: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const appendSpy = jest.spyOn(FormData.prototype, 'append');
|
||||||
|
|
||||||
|
await postSetLang('fr');
|
||||||
|
|
||||||
|
expect(appendSpy).toHaveBeenCalledWith('language', 'fr');
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/i18n/setlang/',
|
||||||
|
expect.any(FormData),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
appendSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,11 +11,12 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
|||||||
|
|
||||||
import AccountSettingsPage from '../AccountSettingsPage';
|
import AccountSettingsPage from '../AccountSettingsPage';
|
||||||
import mockData from './mockData';
|
import mockData from './mockData';
|
||||||
|
import messages from '../AccountSettingsPage.messages';
|
||||||
|
|
||||||
const mockDispatch = jest.fn();
|
const mockDispatch = jest.fn();
|
||||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||||
sendTrackingLogEvent: jest.fn(),
|
sendTrackingLogEvent: jest.fn(),
|
||||||
getCountryList: jest.fn(),
|
getCountryList: jest.fn(() => [{ code: 'US', name: 'United States' }]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('react-redux', () => ({
|
jest.mock('react-redux', () => ({
|
||||||
@@ -25,6 +26,19 @@ jest.mock('react-redux', () => ({
|
|||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform', () => ({
|
||||||
|
...jest.requireActual('@edx/frontend-platform'),
|
||||||
|
getConfig: jest.fn(() => ({
|
||||||
|
SITE_NAME: 'edX',
|
||||||
|
SUPPORT_URL: 'https://support.edx.org',
|
||||||
|
ENABLE_ACCOUNT_DELETION: true,
|
||||||
|
ENABLE_COPPA_COMPLIANCE: false,
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
|
||||||
|
})),
|
||||||
|
getCountryList: jest.fn(() => [{ code: 'US', name: 'United States' }]),
|
||||||
|
getLanguageList: jest.fn(() => [{ code: 'en', name: 'English' }]),
|
||||||
|
}));
|
||||||
|
|
||||||
const IntlAccountSettingsPage = injectIntl(AccountSettingsPage);
|
const IntlAccountSettingsPage = injectIntl(AccountSettingsPage);
|
||||||
|
|
||||||
const middlewares = [thunk];
|
const middlewares = [thunk];
|
||||||
@@ -63,14 +77,67 @@ describe('AccountSettingsPage', () => {
|
|||||||
field_value: '',
|
field_value: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
country: 'US',
|
||||||
|
level_of_education: 'b',
|
||||||
|
gender: 'm',
|
||||||
|
language_proficiencies: 'es',
|
||||||
|
social_link_linkedin: 'https://linkedin.com/in/testuser',
|
||||||
|
social_link_facebook: '',
|
||||||
|
social_link_twitter: '',
|
||||||
|
time_zone: 'America/New_York',
|
||||||
|
state: 'NY',
|
||||||
|
secondary_email_enabled: true,
|
||||||
|
secondary_email: 'test_recovery@test.com',
|
||||||
|
year_of_birth: '1990',
|
||||||
},
|
},
|
||||||
fetchSettings: jest.fn(),
|
fetchSettings: jest.fn(),
|
||||||
|
fetchSiteLanguages: jest.fn(),
|
||||||
|
fetchNotificationPreferences: jest.fn(),
|
||||||
|
saveSettings: jest.fn(),
|
||||||
|
updateDraft: jest.fn(),
|
||||||
|
beginNameChange: jest.fn(),
|
||||||
|
saveMultipleSettings: jest.fn(),
|
||||||
|
timeZoneOptions: [
|
||||||
|
{ label: 'America/New_York', value: 'America/New_York' },
|
||||||
|
],
|
||||||
|
countryTimeZoneOptions: [
|
||||||
|
{ label: 'America/New_York', value: 'America/New_York' },
|
||||||
|
],
|
||||||
|
siteLanguageOptions: [
|
||||||
|
{ label: 'English', value: 'en' },
|
||||||
|
],
|
||||||
|
tpaProviders: [
|
||||||
|
{
|
||||||
|
id: 'oa2-google-oauth2',
|
||||||
|
name: 'Google',
|
||||||
|
connected: false,
|
||||||
|
accepts_logins: true,
|
||||||
|
connectUrl: 'http://localhost:18000/auth/login/google-oauth2/',
|
||||||
|
disconnectUrl: 'http://localhost:18000/auth/disconnect/google-oauth2/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
staticFields: [],
|
||||||
|
profileDataManager: null,
|
||||||
|
verifiedName: null,
|
||||||
|
mostRecentVerifiedName: {},
|
||||||
|
verifiedNameHistory: [],
|
||||||
|
countriesCodesList: ['US'],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks());
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
global.lightningjs = {
|
||||||
|
require: jest.fn().mockImplementation((module, url) => ({ moduleName: module, url })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete global.lightningjs;
|
||||||
|
});
|
||||||
|
|
||||||
it('renders AccountSettingsPage correctly with editing enabled', async () => {
|
it('renders AccountSettingsPage correctly with editing enabled', async () => {
|
||||||
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
|
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
|
||||||
|
|
||||||
@@ -98,4 +165,70 @@ describe('AccountSettingsPage', () => {
|
|||||||
|
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders Account Information section with correct field values', () => {
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.getByText('test_username')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('test_name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('test_email@test.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('test_recovery@test.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1990')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Profile Information section with correct field values', () => {
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.getByText('Bachelor\'s Degree')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Male')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Add work experience')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('English')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Social Media section with correct field values', () => {
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.getByText('https://linkedin.com/in/testuser')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Add Facebook profile')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(messages['account.settings.field.social.platform.name.xTwitter.empty'].defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Site Preferences section with correct field values', () => {
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.getByText('English')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('America/New_York')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Delete Account section when enabled', () => {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { getConfig } = require('@edx/frontend-platform');
|
||||||
|
jest.spyOn({ getConfig }, 'getConfig').mockImplementation(() => ({
|
||||||
|
SITE_NAME: 'edX',
|
||||||
|
SUPPORT_URL: 'https://support.edx.org',
|
||||||
|
ENABLE_ACCOUNT_DELETION: true,
|
||||||
|
ENABLE_COPPA_COMPLIANCE: false,
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.getByText('We\'re sorry to see you go!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Delete Account section when disabled', () => {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { getConfig } = require('@edx/frontend-platform');
|
||||||
|
jest.spyOn({ getConfig }, 'getConfig').mockImplementation(() => ({
|
||||||
|
SITE_NAME: 'edX',
|
||||||
|
SUPPORT_URL: 'https://support.edx.org',
|
||||||
|
ENABLE_ACCOUNT_DELETION: false,
|
||||||
|
ENABLE_COPPA_COMPLIANCE: false,
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(reduxWrapper(<AccountSettingsPage {...props} />));
|
||||||
|
|
||||||
|
expect(screen.queryByText('We\'re sorry to see you go!')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
144
src/account-settings/test/DOBForm.test.jsx
Normal file
144
src/account-settings/test/DOBForm.test.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
render, screen, fireEvent, waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import * as reactRedux from 'react-redux';
|
||||||
|
import DOBModal from '../DOBForm';
|
||||||
|
import messages from '../AccountSettingsPage.messages';
|
||||||
|
import { YEAR_OF_BIRTH_OPTIONS } from '../data/constants';
|
||||||
|
|
||||||
|
jest.mock('react-redux', () => ({
|
||||||
|
...jest.requireActual('react-redux'),
|
||||||
|
useDispatch: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||||
|
useIntl: () => ({
|
||||||
|
formatMessage: (message) => message.defaultMessage,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@openedx/paragon', () => ({
|
||||||
|
...jest.requireActual('@openedx/paragon'),
|
||||||
|
Form: {
|
||||||
|
...jest.requireActual('@openedx/paragon').Form,
|
||||||
|
Control: {
|
||||||
|
...jest.requireActual('@openedx/paragon').Form.Control,
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
|
Feedback: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockStore = configureStore([]);
|
||||||
|
|
||||||
|
describe('DOBModal', () => {
|
||||||
|
let store;
|
||||||
|
let mockDispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = mockStore({
|
||||||
|
accountSettings: {
|
||||||
|
saveState: 'default',
|
||||||
|
errors: {},
|
||||||
|
openFormId: null,
|
||||||
|
confirmationValues: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockDispatch = jest.fn();
|
||||||
|
jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch); // ✅ replaced require with import
|
||||||
|
// Mock localStorage.setItem
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
setItem: jest.fn(),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (props = {}) => render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<DOBModal
|
||||||
|
saveState="default"
|
||||||
|
error={undefined}
|
||||||
|
onSubmit={jest.fn()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders the modal with correct elements', async () => {
|
||||||
|
renderComponent();
|
||||||
|
const openButton = screen.getByTestId('open-modal-button');
|
||||||
|
expect(openButton).toHaveTextContent(messages['account.settings.field.dob.form.button'].defaultMessage);
|
||||||
|
|
||||||
|
fireEvent.click(openButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('modal-title')).toHaveTextContent(messages['account.settings.field.dob.form.title'].defaultMessage);
|
||||||
|
expect(screen.getByTestId('help-text')).toHaveTextContent(messages['account.settings.field.dob.form.help.text'].defaultMessage);
|
||||||
|
expect(screen.getByTestId('month-label')).toHaveTextContent(messages['account.settings.field.dob.month'].defaultMessage);
|
||||||
|
expect(screen.getByTestId('year-label')).toHaveTextContent(messages['account.settings.field.dob.year'].defaultMessage);
|
||||||
|
expect(screen.getByTestId('month-select')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('year-select')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables submit button when both month and year are selected', async () => {
|
||||||
|
renderComponent();
|
||||||
|
const openButton = screen.getByTestId('open-modal-button');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(openButton);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
const monthSelect = screen.getByTestId('month-select');
|
||||||
|
const yearSelect = screen.getByTestId('year-select');
|
||||||
|
const submitButton = screen.getByTestId('submit-button');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(monthSelect, { target: { value: '6' } });
|
||||||
|
fireEvent.change(yearSelect, { target: { value: YEAR_OF_BIRTH_OPTIONS[0].value } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitButton).not.toHaveAttribute('disabled');
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with correct data when form is submitted', async () => {
|
||||||
|
const mockOnSubmit = jest.fn();
|
||||||
|
renderComponent({ onSubmit: mockOnSubmit });
|
||||||
|
|
||||||
|
const openButton = screen.getByTestId('open-modal-button');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(openButton);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
const monthSelect = screen.getByTestId('month-select');
|
||||||
|
const yearSelect = screen.getByTestId('year-select');
|
||||||
|
const form = screen.getByTestId('dob-form');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(monthSelect, { target: { value: '6' } });
|
||||||
|
fireEvent.change(yearSelect, { target: { value: '1990' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.submit(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnSubmit).toHaveBeenCalledWith('extended_profile', [
|
||||||
|
{ field_name: 'DOB', field_value: '1990-6' },
|
||||||
|
]);
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
184
src/account-settings/test/EditableField.test.jsx
Normal file
184
src/account-settings/test/EditableField.test.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
render, screen, fireEvent, waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import EditableField from '../EditableField';
|
||||||
|
import messages from '../AccountSettingsPage.messages';
|
||||||
|
|
||||||
|
jest.mock('../data/selectors', () => ({
|
||||||
|
editableFieldSelector: () => (state, props) => ({
|
||||||
|
...state.accountSettings,
|
||||||
|
isEditing: props.isEditing,
|
||||||
|
error: props.error || state.accountSettings.errors[props.name],
|
||||||
|
confirmationValue: props.confirmationValue || state.accountSettings.confirmationValues[props.name],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../data/actions', () => ({
|
||||||
|
openForm: jest.fn((name) => ({ type: 'OPEN_FORM', payload: name })),
|
||||||
|
closeForm: jest.fn((name) => ({ type: 'CLOSE_FORM', payload: name })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
|
jest.mock('../certificate-preference/CertificatePreference', () => function MockCertificatePreference({ fieldName }) {
|
||||||
|
return <div data-testid="editable-field-certificate-preference">Certificate Preference for {fieldName}</div>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockStore = configureStore([]);
|
||||||
|
const mockOnEdit = jest.fn();
|
||||||
|
const mockOnCancel = jest.fn();
|
||||||
|
const mockOnSubmit = jest.fn();
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
|
||||||
|
const baseState = {
|
||||||
|
accountSettings: {
|
||||||
|
errors: {},
|
||||||
|
confirmationValues: {},
|
||||||
|
saveState: 'default',
|
||||||
|
openFormId: null,
|
||||||
|
verifiedNameHistory: { results: [] },
|
||||||
|
values: {},
|
||||||
|
drafts: {},
|
||||||
|
timeZones: [],
|
||||||
|
countryTimeZones: [],
|
||||||
|
thirdPartyAuth: { providers: [] },
|
||||||
|
countriesCodesList: [],
|
||||||
|
profileDataManager: false,
|
||||||
|
nameChangeModal: {},
|
||||||
|
loading: false,
|
||||||
|
loaded: true,
|
||||||
|
loadingError: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (props = {}, stateOverrides = {}) => {
|
||||||
|
const store = mockStore({
|
||||||
|
...baseState,
|
||||||
|
...stateOverrides,
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<EditableField
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
value="john_doe"
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
onSubmit={mockOnSubmit}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
isEditing={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EditableField', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default state with value', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText('Username')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('john_doe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Edit/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty label with edit button if no value and editable', () => {
|
||||||
|
renderComponent({ value: '', emptyLabel: 'Add value' });
|
||||||
|
expect(screen.getByRole('button', { name: 'Add value' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty label as muted text if not editable', () => {
|
||||||
|
renderComponent({ value: '', emptyLabel: 'No value', isEditable: false });
|
||||||
|
expect(screen.getByText('No value')).toHaveClass('text-muted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders editing state with form controls', async () => {
|
||||||
|
renderComponent({ isEditing: true });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('editable-field-textbox')).toHaveValue('john_doe');
|
||||||
|
expect(screen.getByTestId('editable-field-save')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('editable-field-cancel')).toBeInTheDocument();
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when input changes', async () => {
|
||||||
|
renderComponent({ isEditing: true });
|
||||||
|
await waitFor(() => {
|
||||||
|
const input = screen.getByTestId('editable-field-textbox');
|
||||||
|
fireEvent.change(input, { target: { value: 'new_name' } });
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('username', 'new_name');
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit when form is submitted', async () => {
|
||||||
|
renderComponent({ isEditing: true });
|
||||||
|
await waitFor(() => {
|
||||||
|
const form = screen.getByTestId('editable-field-form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
expect(mockOnSubmit).toHaveBeenCalledWith('username', 'john_doe');
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message when error is present', async () => {
|
||||||
|
const stateOverrides = {
|
||||||
|
accountSettings: {
|
||||||
|
...baseState.accountSettings,
|
||||||
|
errors: { username: 'Invalid input' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderComponent({ isEditing: true, error: 'Invalid input' }, stateOverrides);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('editable-field-error')).toHaveTextContent('Invalid input');
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows help text in editing mode', () => {
|
||||||
|
renderComponent({ isEditing: true, helpText: 'Helpful info' });
|
||||||
|
expect(screen.getByText('Helpful info')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows confirmation message in default mode if provided', async () => {
|
||||||
|
const stateOverrides = {
|
||||||
|
accountSettings: {
|
||||||
|
...baseState.accountSettings,
|
||||||
|
confirmationValues: { username: 'done' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderComponent(
|
||||||
|
{
|
||||||
|
confirmationMessageDefinition: messages['account.settings.editable.field.action.save'],
|
||||||
|
confirmationValue: 'done',
|
||||||
|
},
|
||||||
|
stateOverrides,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('editable-field-confirmation')).toBeInTheDocument();
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders CertificatePreference for name fields when editing', async () => {
|
||||||
|
renderComponent({ isEditing: true, name: 'name' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('editable-field-certificate-preference')).toHaveTextContent('Certificate Preference for name');
|
||||||
|
}, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies grayed-out class when isGrayedOut is true', () => {
|
||||||
|
renderComponent({ isGrayedOut: true });
|
||||||
|
expect(screen.getByText('john_doe')).toHaveClass('grayed-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends userSuppliedValue when provided', () => {
|
||||||
|
renderComponent({ userSuppliedValue: 'extra' });
|
||||||
|
expect(screen.getByText('john_doe: extra')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
|
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import EditableSelectField from '../EditableSelectField';
|
import EditableSelectField from '../EditableSelectField';
|
||||||
|
|
||||||
@@ -17,8 +16,6 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
||||||
|
|
||||||
const IntlEditableSelectField = injectIntl(EditableSelectField);
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('EditableSelectField', () => {
|
describe('EditableSelectField', () => {
|
||||||
@@ -88,7 +85,7 @@ describe('EditableSelectField', () => {
|
|||||||
afterEach(() => jest.clearAllMocks());
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('renders EditableSelectField correctly with editing disabled', () => {
|
it('renders EditableSelectField correctly with editing disabled', () => {
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...props} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +95,7 @@ describe('EditableSelectField', () => {
|
|||||||
isEditing: true,
|
isEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...props} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +104,7 @@ describe('EditableSelectField', () => {
|
|||||||
...props,
|
...props,
|
||||||
error: 'This is an error message',
|
error: 'This is an error message',
|
||||||
};
|
};
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...errorProps} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...errorProps} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +123,7 @@ describe('EditableSelectField', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroup} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroup} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +137,7 @@ describe('EditableSelectField', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithoutGroup} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithoutGroup} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,7 +160,7 @@ describe('EditableSelectField', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroups} />)).toJSON();
|
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroups} />)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform';
|
import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import JumpNav from '../JumpNav';
|
import JumpNav from '../JumpNav';
|
||||||
import configureStore from '../../data/configureStore';
|
import configureStore from '../../data/configureStore';
|
||||||
|
|
||||||
const IntlJumpNav = injectIntl(JumpNav);
|
|
||||||
|
|
||||||
describe('JumpNav', () => {
|
describe('JumpNav', () => {
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
ENABLE_ACCOUNT_DELETION: true,
|
ENABLE_ACCOUNT_DELETION: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let props = {};
|
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -27,9 +23,6 @@ describe('JumpNav', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
props = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
store = configureStore({
|
store = configureStore({
|
||||||
notificationPreferences: {
|
notificationPreferences: {
|
||||||
showPreferences: false,
|
showPreferences: false,
|
||||||
@@ -45,7 +38,7 @@ describe('JumpNav', () => {
|
|||||||
render(
|
render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<IntlJumpNav {...props} />
|
<JumpNav />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
@@ -58,14 +51,10 @@ describe('JumpNav', () => {
|
|||||||
ENABLE_ACCOUNT_DELETION: true,
|
ENABLE_ACCOUNT_DELETION: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
props = {
|
|
||||||
...props,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<IntlJumpNav {...props} />
|
<JumpNav />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const mockData = {
|
|||||||
data: null,
|
data: null,
|
||||||
values: {
|
values: {
|
||||||
username: 'test_username',
|
username: 'test_username',
|
||||||
country: 'AD',
|
country: 'US',
|
||||||
accomplishments_shared: false,
|
accomplishments_shared: false,
|
||||||
name: 'test_name',
|
name: 'test_name',
|
||||||
email: 'test_email@test.com',
|
email: 'test_email@test.com',
|
||||||
@@ -18,8 +18,18 @@ const mockData = {
|
|||||||
field_value: '',
|
field_value: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
gender: null,
|
gender: 'm',
|
||||||
'pref-lang': 'en',
|
'pref-lang': 'en',
|
||||||
|
level_of_education: 'b',
|
||||||
|
language_proficiencies: 'es',
|
||||||
|
social_link_linkedin: 'https://linkedin.com/in/testuser',
|
||||||
|
social_link_facebook: '',
|
||||||
|
social_link_twitter: '',
|
||||||
|
time_zone: 'America/New_York',
|
||||||
|
state: 'NY',
|
||||||
|
secondary_email_enabled: true,
|
||||||
|
secondary_email: 'test_recovery@test.com',
|
||||||
|
year_of_birth: '1990',
|
||||||
},
|
},
|
||||||
errors: {},
|
errors: {},
|
||||||
confirmationValues: {},
|
confirmationValues: {},
|
||||||
@@ -27,14 +37,14 @@ const mockData = {
|
|||||||
saveState: null,
|
saveState: null,
|
||||||
timeZones: [
|
timeZones: [
|
||||||
{
|
{
|
||||||
time_zone: 'Africa/Abidjan',
|
time_zone: 'America/New_York',
|
||||||
description: 'Africa/Abidjan (GMT, UTC+0000)',
|
description: 'America/New_York (EST, UTC-0500)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
countryTimeZones: [
|
countryTimeZones: [
|
||||||
{
|
{
|
||||||
time_zone: 'Europe/Andorra',
|
time_zone: 'America/New_York',
|
||||||
description: 'Europe/Andorra (CET, UTC+0100)',
|
description: 'America/New_York (EST, UTC-0500)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
previousSiteLanguage: null,
|
previousSiteLanguage: null,
|
||||||
@@ -84,7 +94,7 @@ const mockData = {
|
|||||||
profileDataManager: null,
|
profileDataManager: null,
|
||||||
},
|
},
|
||||||
notificationPreferences: {
|
notificationPreferences: {
|
||||||
showPreferences: false,
|
showPreferences: true,
|
||||||
courses: {
|
courses: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
courses: [],
|
courses: [],
|
||||||
@@ -98,7 +108,7 @@ const mockData = {
|
|||||||
preferences: {
|
preferences: {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
updatePreferenceStatus: 'idle',
|
updatePreferenceStatus: 'idle',
|
||||||
selectedCourse: null,
|
selectedCourse: 'account',
|
||||||
preferences: [],
|
preferences: [],
|
||||||
apps: [],
|
apps: [],
|
||||||
nonEditable: {},
|
nonEditable: {},
|
||||||
|
|||||||
16
src/divider/Divider.jsx
Normal file
16
src/divider/Divider.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
const Divider = ({ className, ...props }) => (
|
||||||
|
<div className={classNames('divider', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Divider.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
Divider.defaultProps = {
|
||||||
|
className: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Divider;
|
||||||
2
src/divider/index.jsx
Normal file
2
src/divider/index.jsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export { default as Divider } from './Divider';
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const Head = ({ intl }) => (
|
const Head = () => {
|
||||||
<Helmet>
|
const intl = useIntl();
|
||||||
<title>
|
return (
|
||||||
{intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })}
|
<Helmet>
|
||||||
</title>
|
<title>
|
||||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
{intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })}
|
||||||
</Helmet>
|
</title>
|
||||||
);
|
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||||
|
</Helmet>
|
||||||
Head.propTypes = {
|
);
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Head);
|
export default Head;
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { getConfig } from '@edx/frontend-platform';
|
|||||||
import Head from './Head';
|
import Head from './Head';
|
||||||
|
|
||||||
describe('Head', () => {
|
describe('Head', () => {
|
||||||
const props = {};
|
|
||||||
it('should match render title tag and fivicon with the site configuration values', () => {
|
it('should match render title tag and fivicon with the site configuration values', () => {
|
||||||
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
|
render(<IntlProvider locale="en"><Head /></IntlProvider>);
|
||||||
const helmet = Helmet.peek();
|
const helmet = Helmet.peek();
|
||||||
expect(helmet.title).toEqual(`Account | ${getConfig().SITE_NAME}`);
|
expect(helmet.title).toEqual(`Account | ${getConfig().SITE_NAME}`);
|
||||||
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
|
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
|
||||||
|
|||||||
@@ -71,5 +71,5 @@ export function useFeedbackWrapper() {
|
|||||||
|
|
||||||
export function useIsOnMobile() {
|
export function useIsOnMobile() {
|
||||||
const windowSize = useWindowSize();
|
const windowSize = useWindowSize();
|
||||||
return windowSize.width <= breakpoints.small.minWidth;
|
return windowSize.width <= breakpoints.small.maxWidth;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
import { ERROR_REASONS } from './IdVerificationContext';
|
import { ERROR_REASONS } from './IdVerificationContext';
|
||||||
|
|
||||||
const AccessBlocked = ({ error, intl }) => {
|
const AccessBlocked = ({ error }) => {
|
||||||
|
const intl = useIntl();
|
||||||
const handleMessage = () => {
|
const handleMessage = () => {
|
||||||
if (error === ERROR_REASONS.COURSE_ENROLLMENT) {
|
if (error === ERROR_REASONS.COURSE_ENROLLMENT) {
|
||||||
return <p>{intl.formatMessage(messages['id.verification.access.blocked.enrollment'])}</p>;
|
return <p>{intl.formatMessage(messages['id.verification.access.blocked.enrollment'])}</p>;
|
||||||
@@ -42,8 +43,7 @@ const AccessBlocked = ({ error, intl }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
AccessBlocked.propTypes = {
|
AccessBlocked.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
error: PropTypes.string.isRequired,
|
error: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AccessBlocked);
|
export default AccessBlocked;
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Collapsible } from '@openedx/paragon';
|
import { Collapsible } from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
|
|
||||||
const CameraHelp = (props) => (
|
const CameraHelp = (props) => {
|
||||||
<div>
|
const intl = useIntl();
|
||||||
<Collapsible
|
|
||||||
styling="card"
|
return (
|
||||||
title={props.intl.formatMessage(messages['id.verification.camera.help.sight.question'])}
|
<div>
|
||||||
className="mb-4 shadow"
|
<Collapsible
|
||||||
defaultOpen={props.isOpen}
|
styling="card"
|
||||||
>
|
title={intl.formatMessage(messages['id.verification.camera.help.sight.question'])}
|
||||||
<p>
|
className="mb-4 shadow"
|
||||||
{props.intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])}
|
defaultOpen={props.isOpen}
|
||||||
</p>
|
>
|
||||||
</Collapsible>
|
<p>
|
||||||
<Collapsible
|
{intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])}
|
||||||
styling="card"
|
</p>
|
||||||
title={props.intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])}
|
</Collapsible>
|
||||||
className="mb-4 shadow"
|
<Collapsible
|
||||||
defaultOpen={props.isOpen}
|
styling="card"
|
||||||
>
|
title={intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])}
|
||||||
<p>
|
className="mb-4 shadow"
|
||||||
{props.intl.formatMessage(
|
defaultOpen={props.isOpen}
|
||||||
messages['id.verification.camera.help.difficulty.answer'],
|
>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p>
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages['id.verification.camera.help.difficulty.answer'],
|
||||||
</Collapsible>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
</div>
|
)}
|
||||||
);
|
</p>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
CameraHelp.propTypes = {
|
CameraHelp.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
isPortrait: PropTypes.bool,
|
isPortrait: PropTypes.bool,
|
||||||
};
|
};
|
||||||
@@ -45,4 +48,4 @@ CameraHelp.defaultProps = {
|
|||||||
isPortrait: false,
|
isPortrait: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CameraHelp);
|
export default CameraHelp;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useContext } from 'react';
|
import { useState, useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Collapsible } from '@openedx/paragon';
|
import { Collapsible } from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
|
|
||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
@@ -11,6 +11,7 @@ import ImagePreview from './ImagePreview';
|
|||||||
import SupportedMediaTypes from './SupportedMediaTypes';
|
import SupportedMediaTypes from './SupportedMediaTypes';
|
||||||
|
|
||||||
const CameraHelpWithUpload = (props) => {
|
const CameraHelpWithUpload = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
const { setIdPhotoFile, idPhotoFile, userId } = useContext(IdVerificationContext);
|
const { setIdPhotoFile, idPhotoFile, userId } = useContext(IdVerificationContext);
|
||||||
const [hasUploadedImage, setHasUploadedImage] = useState(false);
|
const [hasUploadedImage, setHasUploadedImage] = useState(false);
|
||||||
|
|
||||||
@@ -27,24 +28,23 @@ const CameraHelpWithUpload = (props) => {
|
|||||||
<div>
|
<div>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
styling="card"
|
styling="card"
|
||||||
title={props.intl.formatMessage(messages['id.verification.id.photo.unclear.question'])}
|
title={intl.formatMessage(messages['id.verification.id.photo.unclear.question'])}
|
||||||
data-testid="collapsible"
|
data-testid="collapsible"
|
||||||
className="mb-4 shadow"
|
className="mb-4 shadow"
|
||||||
defaultOpen={props.isOpen}
|
defaultOpen={props.isOpen}
|
||||||
>
|
>
|
||||||
{idPhotoFile && hasUploadedImage && <ImagePreview src={idPhotoFile} alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
|
{idPhotoFile && hasUploadedImage && <ImagePreview src={idPhotoFile} alt={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
|
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
|
||||||
<SupportedMediaTypes />
|
<SupportedMediaTypes />
|
||||||
</p>
|
</p>
|
||||||
<ImageFileUpload onFileChange={setAndTrackIdPhotoFile} intl={props.intl} />
|
<ImageFileUpload onFileChange={setAndTrackIdPhotoFile} />
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CameraHelpWithUpload.propTypes = {
|
CameraHelpWithUpload.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,4 +52,4 @@ CameraHelpWithUpload.defaultProps = {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CameraHelpWithUpload);
|
export default CameraHelpWithUpload;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Collapsible } from '@openedx/paragon';
|
import { Button, Collapsible } from '@openedx/paragon';
|
||||||
|
|
||||||
import IdVerificationContext from './IdVerificationContext';
|
import IdVerificationContext from './IdVerificationContext';
|
||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
|
|
||||||
const CollapsibleImageHelp = (props) => {
|
const CollapsibleImageHelp = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
userId, useCameraForId, setUseCameraForId,
|
userId, useCameraForId, setUseCameraForId,
|
||||||
} = useContext(IdVerificationContext);
|
} = useContext(IdVerificationContext);
|
||||||
@@ -25,15 +26,15 @@ const CollapsibleImageHelp = (props) => {
|
|||||||
<Collapsible
|
<Collapsible
|
||||||
styling="card"
|
styling="card"
|
||||||
title={useCameraForId
|
title={useCameraForId
|
||||||
? props.intl.formatMessage(messages['id.verification.photo.upload.help.title'])
|
? intl.formatMessage(messages['id.verification.photo.upload.help.title'])
|
||||||
: props.intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
|
: intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
|
||||||
className="mb-4 shadow"
|
className="mb-4 shadow"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<p data-testid="help-text">
|
<p data-testid="help-text">
|
||||||
{useCameraForId
|
{useCameraForId
|
||||||
? props.intl.formatMessage(messages['id.verification.photo.upload.help.text'])
|
? intl.formatMessage(messages['id.verification.photo.upload.help.text'])
|
||||||
: props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
|
: intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
title={useCameraForId ? 'Upload Photo' : 'Take Photo'} // TO-DO: translation
|
title={useCameraForId ? 'Upload Photo' : 'Take Photo'} // TO-DO: translation
|
||||||
@@ -42,15 +43,11 @@ const CollapsibleImageHelp = (props) => {
|
|||||||
style={{ marginTop: '0.5rem' }}
|
style={{ marginTop: '0.5rem' }}
|
||||||
>
|
>
|
||||||
{useCameraForId
|
{useCameraForId
|
||||||
? props.intl.formatMessage(messages['id.verification.photo.upload.help.button'])
|
? intl.formatMessage(messages['id.verification.photo.upload.help.button'])
|
||||||
: props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
|
: intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
|
||||||
</Button>
|
</Button>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CollapsibleImageHelp.propTypes = {
|
export default CollapsibleImageHelp;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CollapsibleImageHelp);
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a driver’s license or passport.',
|
defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a driver’s license or passport.',
|
||||||
description: 'Text that explains that the user needs a photo ID.',
|
description: 'Text that explains that the user needs a photo ID.',
|
||||||
},
|
},
|
||||||
|
'id.verification.privacy.modal.close.button': {
|
||||||
|
id: 'id.verification.privacy.modal.close.button',
|
||||||
|
defaultMessage: 'Close',
|
||||||
|
description: 'Label on button to close privacy information dialog.',
|
||||||
|
},
|
||||||
'id.verification.privacy.title': {
|
'id.verification.privacy.title': {
|
||||||
id: 'id.verification.privacy.title',
|
id: 'id.verification.privacy.title',
|
||||||
defaultMessage: 'Privacy Information',
|
defaultMessage: 'Privacy Information',
|
||||||
@@ -656,6 +661,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Switch to Camera Mode',
|
defaultMessage: 'Switch to Camera Mode',
|
||||||
description: 'Button used to switch to camera mode.',
|
description: 'Button used to switch to camera mode.',
|
||||||
},
|
},
|
||||||
|
'id.verification.context.loading.state': {
|
||||||
|
id: 'id.verification.context.loading.state',
|
||||||
|
defaultMessage: 'Loading verification status',
|
||||||
|
description: 'Message shown for screen readers when a user\'s identification verification is in the loading state',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { getProfileDataManager } from '../account-settings/data/service';
|
import { getProfileDataManager } from '../account-settings/data/service';
|
||||||
import PageLoading from '../account-settings/PageLoading';
|
import PageLoading from '../account-settings/PageLoading';
|
||||||
@@ -15,7 +16,10 @@ import { hasGetUserMediaSupport } from './getUserMediaShim';
|
|||||||
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
|
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
|
||||||
import { VerifiedNameContext } from './VerifiedNameContext';
|
import { VerifiedNameContext } from './VerifiedNameContext';
|
||||||
|
|
||||||
|
import messages from './IdVerification.messages';
|
||||||
|
|
||||||
const IdVerificationContextProvider = ({ children }) => {
|
const IdVerificationContextProvider = ({ children }) => {
|
||||||
|
const intl = useIntl();
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
const { verifiedNameHistoryCallStatus, verifiedName } = useContext(VerifiedNameContext);
|
const { verifiedNameHistoryCallStatus, verifiedName } = useContext(VerifiedNameContext);
|
||||||
|
|
||||||
@@ -117,7 +121,7 @@ const IdVerificationContextProvider = ({ children }) => {
|
|||||||
const loadingStatuses = [IDLE_STATUS, LOADING_STATUS];
|
const loadingStatuses = [IDLE_STATUS, LOADING_STATUS];
|
||||||
// If we are waiting for verification status or verified name history endpoint, show spinner.
|
// If we are waiting for verification status or verified name history endpoint, show spinner.
|
||||||
if (loadingStatuses.includes(idVerificationData.status) || loadingStatuses.includes(verifiedNameHistoryCallStatus)) {
|
if (loadingStatuses.includes(idVerificationData.status) || loadingStatuses.includes(verifiedNameHistoryCallStatus)) {
|
||||||
return <PageLoading srMessage="Loading verification status" />;
|
return <PageLoading srMessage={intl.formatMessage(messages['id.verification.context.loading.state'])} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canVerify) {
|
if (!canVerify) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Route, Routes, useLocation, useNavigate,
|
Route, Routes, useLocation, useNavigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import camelCase from 'lodash.camelcase';
|
import camelCase from 'lodash.camelcase';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, ModalDialog, ActionRow } from '@openedx/paragon';
|
import { Button, ModalDialog, ActionRow } from '@openedx/paragon';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { idVerificationSelector } from './data/selectors';
|
import { idVerificationSelector } from './data/selectors';
|
||||||
@@ -26,9 +26,10 @@ import SubmittedPanel from './panels/SubmittedPanel';
|
|||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
|
|
||||||
// eslint-disable-next-line react/prefer-stateless-function
|
// eslint-disable-next-line react/prefer-stateless-function
|
||||||
const IdVerificationPage = (props) => {
|
const IdVerificationPage = () => {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -78,33 +79,33 @@ const IdVerificationPage = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
title="Id modal"
|
title={intl.formatMessage(messages['id.verification.privacy.title'])}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
size="lg"
|
size="lg"
|
||||||
hasCloseButton={false}
|
hasCloseButton={false}
|
||||||
>
|
>
|
||||||
<ModalDialog.Header>
|
<ModalDialog.Header>
|
||||||
<ModalDialog.Title data-testid="Id-modal">
|
<ModalDialog.Title data-testid="Id-modal">
|
||||||
{props.intl.formatMessage(messages['id.verification.privacy.title'])}
|
{intl.formatMessage(messages['id.verification.privacy.title'])}
|
||||||
</ModalDialog.Title>
|
</ModalDialog.Title>
|
||||||
</ModalDialog.Header>
|
</ModalDialog.Header>
|
||||||
<ModalDialog.Body>
|
<ModalDialog.Body>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h6>
|
<h6>
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.need.photo.question'],
|
messages['id.verification.privacy.need.photo.question'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
</h6>
|
</h6>
|
||||||
<p>{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p>
|
<p>{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p>
|
||||||
<h6>
|
<h6>
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.do.with.photo.question'],
|
messages['id.verification.privacy.do.with.photo.question'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
</h6>
|
</h6>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.do.with.photo.answer'],
|
messages['id.verification.privacy.do.with.photo.answer'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
@@ -114,7 +115,7 @@ const IdVerificationPage = (props) => {
|
|||||||
<ModalDialog.Footer className="p-2">
|
<ModalDialog.Footer className="p-2">
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<ModalDialog.CloseButton variant="link">
|
<ModalDialog.CloseButton variant="link">
|
||||||
Close
|
{intl.formatMessage(messages['id.verification.privacy.modal.close.button'])}
|
||||||
</ModalDialog.CloseButton>
|
</ModalDialog.CloseButton>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
</ModalDialog.Footer>
|
</ModalDialog.Footer>
|
||||||
@@ -124,8 +125,4 @@ const IdVerificationPage = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
IdVerificationPage.propTypes = {
|
export default connect(idVerificationSelector, {})(IdVerificationPage);
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(idVerificationSelector, {})(injectIntl(IdVerificationPage));
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Alert } from '@openedx/paragon';
|
import { Alert } from '@openedx/paragon';
|
||||||
import messages from './IdVerification.messages';
|
import messages from './IdVerification.messages';
|
||||||
import SupportedMediaTypes from './SupportedMediaTypes';
|
import SupportedMediaTypes from './SupportedMediaTypes';
|
||||||
|
|
||||||
const ImageFileUpload = ({ onFileChange, intl }) => {
|
const ImageFileUpload = ({ onFileChange }) => {
|
||||||
|
const intl = useIntl();
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const errorTypes = {
|
const errorTypes = {
|
||||||
invalidFileType: 'invalidFileType',
|
invalidFileType: 'invalidFileType',
|
||||||
@@ -58,7 +59,6 @@ const ImageFileUpload = ({ onFileChange, intl }) => {
|
|||||||
|
|
||||||
ImageFileUpload.propTypes = {
|
ImageFileUpload.propTypes = {
|
||||||
onFileChange: PropTypes.func.isRequired,
|
onFileChange: PropTypes.func.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageFileUpload;
|
export default ImageFileUpload;
|
||||||
|
|||||||
147
src/id-verification/data/service.test.js
Normal file
147
src/id-verification/data/service.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import qs from 'qs';
|
||||||
|
import { getExistingIdVerification, getEnrollments, submitIdVerification } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform', () => {
|
||||||
|
const actual = jest.requireActual('@edx/frontend-platform');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getConfig: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||||
|
getAuthenticatedHttpClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('qs');
|
||||||
|
|
||||||
|
describe('ID Verification Service', () => {
|
||||||
|
let mockHttpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://test.lms' });
|
||||||
|
|
||||||
|
mockHttpClient = {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExistingIdVerification', () => {
|
||||||
|
it('returns transformed data on success', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { status: 'approved', expires: '2025-12-01', can_verify: true },
|
||||||
|
};
|
||||||
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await getExistingIdVerification();
|
||||||
|
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||||
|
'http://test.lms/verify_student/status/',
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'approved',
|
||||||
|
expires: '2025-12-01',
|
||||||
|
canVerify: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns defaults on failure', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await getExistingIdVerification();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: null,
|
||||||
|
expires: null,
|
||||||
|
canVerify: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEnrollments', () => {
|
||||||
|
it('returns data on success', async () => {
|
||||||
|
const mockResponse = { data: [{ course_id: 'course-v1:test+T101+2025', mode: 'verified' }] };
|
||||||
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await getEnrollments();
|
||||||
|
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||||
|
'http://test.lms/api/enrollment/v1/enrollment',
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object on failure', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('Server error'));
|
||||||
|
|
||||||
|
const result = await getEnrollments();
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submitIdVerification', () => {
|
||||||
|
it('posts transformed data and returns success', async () => {
|
||||||
|
const verificationData = {
|
||||||
|
facePhotoFile: 'face-img',
|
||||||
|
idPhotoFile: 'id-img',
|
||||||
|
idPhotoName: 'John Doe',
|
||||||
|
courseRunKey: 'course-v1:test+T101+2025',
|
||||||
|
};
|
||||||
|
const expectedPostData = {
|
||||||
|
face_image: 'face-img',
|
||||||
|
photo_id_image: 'id-img',
|
||||||
|
full_name: 'John Doe',
|
||||||
|
};
|
||||||
|
qs.stringify.mockReturnValue('encoded-data');
|
||||||
|
mockHttpClient.post.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await submitIdVerification(verificationData);
|
||||||
|
|
||||||
|
expect(qs.stringify).toHaveBeenCalledWith(expectedPostData);
|
||||||
|
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
||||||
|
'http://test.lms/verify_student/submit-photos/',
|
||||||
|
'encoded-data',
|
||||||
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ success: true, message: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits null/undefined values', async () => {
|
||||||
|
const verificationData = {
|
||||||
|
facePhotoFile: 'face-img',
|
||||||
|
idPhotoFile: null,
|
||||||
|
idPhotoName: undefined,
|
||||||
|
};
|
||||||
|
qs.stringify.mockReturnValue('encoded-data');
|
||||||
|
mockHttpClient.post.mockResolvedValue({});
|
||||||
|
|
||||||
|
await submitIdVerification(verificationData);
|
||||||
|
|
||||||
|
expect(qs.stringify).toHaveBeenCalledWith({ face_image: 'face-img' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure object on error', async () => {
|
||||||
|
const error = new Error('Failed');
|
||||||
|
error.customAttributes = { httpErrorStatus: 400 };
|
||||||
|
mockHttpClient.post.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await submitIdVerification({ facePhotoFile: 'face-img' });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
status: 400,
|
||||||
|
message: expect.stringContaining('Failed'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
export const EnableCameraDirectionsPanel = (props) => {
|
export const EnableCameraDirectionsPanel = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
if (props.browserName === 'Internet Explorer') {
|
if (props.browserName === 'Internet Explorer') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6>
|
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6>
|
||||||
<ol>
|
<ol>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step1'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step1'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step2'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step2'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step3'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step3'])}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -19,17 +19,17 @@ export const EnableCameraDirectionsPanel = (props) => {
|
|||||||
if (props.browserName === 'Chrome') {
|
if (props.browserName === 'Chrome') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6>
|
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6>
|
||||||
<ol>
|
<ol>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step1'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step1'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.windows'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.windows'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step3'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step3'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step4'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step4'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step5'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step5'])}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -37,15 +37,15 @@ export const EnableCameraDirectionsPanel = (props) => {
|
|||||||
if (props.browserName === 'Firefox') {
|
if (props.browserName === 'Firefox') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6>
|
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6>
|
||||||
<ol>
|
<ol>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step1'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step1'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step2'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step2'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step3'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step3'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step4'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step4'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step5'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step5'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step6'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step6'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step7'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step7'])}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -53,12 +53,12 @@ export const EnableCameraDirectionsPanel = (props) => {
|
|||||||
if (props.browserName === 'Safari') {
|
if (props.browserName === 'Safari') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6>
|
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6>
|
||||||
<ol>
|
<ol>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step1'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step1'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step2'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step2'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step3'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step3'])}</li>
|
||||||
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li>
|
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -68,8 +68,7 @@ export const EnableCameraDirectionsPanel = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
EnableCameraDirectionsPanel.propTypes = {
|
EnableCameraDirectionsPanel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
browserName: PropTypes.string.isRequired,
|
browserName: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EnableCameraDirectionsPanel);
|
export default EnableCameraDirectionsPanel;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, {
|
import {
|
||||||
useContext, useEffect, useRef,
|
useContext, useEffect, useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Form } from '@openedx/paragon';
|
import { Form } from '@openedx/paragon';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
@@ -11,12 +11,13 @@ import IdVerificationContext from '../IdVerificationContext';
|
|||||||
|
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
const GetNameIdPanel = (props) => {
|
const GetNameIdPanel = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const nameInputRef = useRef();
|
const nameInputRef = useRef();
|
||||||
const panelSlug = 'get-name-id';
|
const panelSlug = 'get-name-id';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const { nameOnAccount, idPhotoName, setIdPhotoName } = useContext(IdVerificationContext);
|
const { nameOnAccount, idPhotoName, setIdPhotoName } = useContext(IdVerificationContext);
|
||||||
const nameOnAccountValue = nameOnAccount || '';
|
const nameOnAccountValue = nameOnAccount || '';
|
||||||
@@ -41,19 +42,19 @@ const GetNameIdPanel = (props) => {
|
|||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.name.check.title'])}
|
title={intl.formatMessage(messages['id.verification.name.check.title'])}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.name.check.instructions'])}
|
{intl.formatMessage(messages['id.verification.name.check.instructions'])}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.name.check.mismatch.information'])}
|
{intl.formatMessage(messages['id.verification.name.check.mismatch.information'])}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label className="font-weight-bold" htmlFor="photo-id-name">
|
<Form.Label className="font-weight-bold" htmlFor="photo-id-name">
|
||||||
{props.intl.formatMessage(messages['id.verification.name.label'])}
|
{intl.formatMessage(messages['id.verification.name.label'])}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
controlId="photo-id-name"
|
controlId="photo-id-name"
|
||||||
@@ -72,7 +73,7 @@ const GetNameIdPanel = (props) => {
|
|||||||
data-testid="id-name-feedback-message"
|
data-testid="id-name-feedback-message"
|
||||||
type="invalid"
|
type="invalid"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['id.verification.name.error'])}
|
{intl.formatMessage(messages['id.verification.name.error'])}
|
||||||
</Form.Control.Feedback>
|
</Form.Control.Feedback>
|
||||||
)}
|
)}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
@@ -85,15 +86,11 @@ const GetNameIdPanel = (props) => {
|
|||||||
data-testid="next-button"
|
data-testid="next-button"
|
||||||
aria-disabled={!idPhotoName}
|
aria-disabled={!idPhotoName}
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
GetNameIdPanel.propTypes = {
|
export default GetNameIdPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(GetNameIdPanel);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
@@ -8,49 +8,46 @@ import CameraHelp from '../CameraHelp';
|
|||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
import exampleCard from '../assets/example-card.png';
|
import exampleCard from '../assets/example-card.png';
|
||||||
|
|
||||||
const IdContextPanel = (props) => {
|
const IdContextPanel = () => {
|
||||||
const panelSlug = 'id-context';
|
const panelSlug = 'id-context';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
|
const intl = useIntl();
|
||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.id.tips.title'])}
|
title={intl.formatMessage(messages['id.verification.id.tips.title'])}
|
||||||
>
|
>
|
||||||
<p>{props.intl.formatMessage(messages['id.verification.id.tips.description'])}</p>
|
<p>{intl.formatMessage(messages['id.verification.id.tips.description'])}</p>
|
||||||
<div className="card mb-4 shadow accent border-warning">
|
<div className="card mb-4 shadow accent border-warning">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6>
|
<h6>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
|
||||||
</h6>
|
</h6>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
{props.intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])}
|
{intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{props.intl.formatMessage(messages['id.verification.id.tips.list.clear'])}
|
{intl.formatMessage(messages['id.verification.id.tips.list.clear'])}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<img
|
<img
|
||||||
src={exampleCard}
|
src={exampleCard}
|
||||||
alt={props.intl.formatMessage(messages['id.verification.example.card.alt'])}
|
alt={intl.formatMessage(messages['id.verification.example.card.alt'])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CameraHelp isOpen />
|
<CameraHelp isOpen />
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
IdContextPanel.propTypes = {
|
export default IdContextPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(IdContextPanel);
|
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
import CameraHelp from '../CameraHelp';
|
import CameraHelp from '../CameraHelp';
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
const PortraitPhotoContextPanel = (props) => {
|
const PortraitPhotoContextPanel = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const panelSlug = 'portrait-photo-context';
|
const panelSlug = 'portrait-photo-context';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.photo.tips.title'])}
|
title={intl.formatMessage(messages['id.verification.photo.tips.title'])}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.description'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.description'])}
|
||||||
</p>
|
</p>
|
||||||
<div className="card mb-4 shadow accent border-warning">
|
<div className="card mb-4 shadow accent border-warning">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6>
|
<h6>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
|
||||||
</h6>
|
</h6>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mb-0">
|
<ul className="mb-0">
|
||||||
<li>
|
<li>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{props.intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])}
|
{intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,15 +39,11 @@ const PortraitPhotoContextPanel = (props) => {
|
|||||||
<CameraHelp isOpen isPortrait />
|
<CameraHelp isOpen isPortrait />
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PortraitPhotoContextPanel.propTypes = {
|
export default PortraitPhotoContextPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PortraitPhotoContextPanel);
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useContext } from 'react';
|
import { useEffect, useContext } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Bowser from 'bowser';
|
import Bowser from 'bowser';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useRedirect } from '../../hooks';
|
import { useRedirect } from '../../hooks';
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
@@ -14,7 +14,8 @@ import { UnsupportedCameraDirectionsPanel } from './UnsupportedCameraDirectionsP
|
|||||||
|
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
const RequestCameraAccessPanel = (props) => {
|
const RequestCameraAccessPanel = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const { location: returnUrl, text: returnText } = useRedirect();
|
const { location: returnUrl, text: returnText } = useRedirect();
|
||||||
const panelSlug = 'request-camera-access';
|
const panelSlug = 'request-camera-access';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
@@ -40,17 +41,17 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
|
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
|
||||||
return props.intl.formatMessage(messages['id.verification.camera.access.title.success']);
|
return intl.formatMessage(messages['id.verification.camera.access.title.success']);
|
||||||
}
|
}
|
||||||
if ([MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess)) {
|
if ([MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess)) {
|
||||||
return props.intl.formatMessage(messages['id.verification.camera.access.title.failed']);
|
return intl.formatMessage(messages['id.verification.camera.access.title.failed']);
|
||||||
}
|
}
|
||||||
return props.intl.formatMessage(messages['id.verification.camera.access.title']);
|
return intl.formatMessage(messages['id.verification.camera.access.title']);
|
||||||
};
|
};
|
||||||
|
|
||||||
const returnLink = (
|
const returnLink = (
|
||||||
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}>
|
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}>
|
||||||
{props.intl.formatMessage(messages[returnText])}
|
{intl.formatMessage(messages[returnText])}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,13 +68,13 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
defaultMessage="In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. {clickAllow}"
|
defaultMessage="In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. {clickAllow}"
|
||||||
description="Instructions to enable camera access."
|
description="Instructions to enable camera access."
|
||||||
values={{
|
values={{
|
||||||
clickAllow: <strong>{props.intl.formatMessage(messages['id.verification.camera.access.click.allow'])}</strong>,
|
clickAllow: <strong>{intl.formatMessage(messages['id.verification.camera.access.click.allow'])}</strong>,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<button type="button" className="btn btn-primary" onClick={tryGetUserMedia}>
|
<button type="button" className="btn btn-primary" onClick={tryGetUserMedia}>
|
||||||
{props.intl.formatMessage(messages['id.verification.camera.access.enable'])}
|
{intl.formatMessage(messages['id.verification.camera.access.enable'])}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,11 +83,11 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
{mediaAccess === MEDIA_ACCESS.GRANTED && (
|
{mediaAccess === MEDIA_ACCESS.GRANTED && (
|
||||||
<div>
|
<div>
|
||||||
<p data-testid="camera-access-success">
|
<p data-testid="camera-access-success">
|
||||||
{props.intl.formatMessage(messages['id.verification.camera.access.success'])}
|
{intl.formatMessage(messages['id.verification.camera.access.success'])}
|
||||||
</p>
|
</p>
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +96,9 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
{mediaAccess === MEDIA_ACCESS.DENIED && (
|
{mediaAccess === MEDIA_ACCESS.DENIED && (
|
||||||
<div data-testid="camera-failure-instructions">
|
<div data-testid="camera-failure-instructions">
|
||||||
<p data-testid="camera-access-failure">
|
<p data-testid="camera-access-failure">
|
||||||
{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])}
|
{intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])}
|
||||||
</p>
|
</p>
|
||||||
<EnableCameraDirectionsPanel browserName={browserName} intl={props.intl} />
|
<EnableCameraDirectionsPanel browserName={browserName} />
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
{returnLink}
|
{returnLink}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,9 +108,9 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
{mediaAccess === MEDIA_ACCESS.UNSUPPORTED && (
|
{mediaAccess === MEDIA_ACCESS.UNSUPPORTED && (
|
||||||
<div data-testid="camera-unsupported-instructions">
|
<div data-testid="camera-unsupported-instructions">
|
||||||
<p data-testid="camera-unsupported-failure">
|
<p data-testid="camera-unsupported-failure">
|
||||||
{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])}
|
{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])}
|
||||||
</p>
|
</p>
|
||||||
<UnsupportedCameraDirectionsPanel browserName={browserName} intl={props.intl} />
|
<UnsupportedCameraDirectionsPanel browserName={browserName} />
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
{returnLink}
|
{returnLink}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,8 +121,4 @@ const RequestCameraAccessPanel = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
RequestCameraAccessPanel.propTypes = {
|
export default RequestCameraAccessPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(RequestCameraAccessPanel);
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useContext } from 'react';
|
import { useEffect, useContext } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
@@ -12,7 +12,8 @@ import IdVerificationContext from '../IdVerificationContext';
|
|||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
import exampleCard from '../assets/example-card.png';
|
import exampleCard from '../assets/example-card.png';
|
||||||
|
|
||||||
const ReviewRequirementsPanel = (props) => {
|
const ReviewRequirementsPanel = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const { userId, profileDataManager } = useContext(IdVerificationContext);
|
const { userId, profileDataManager } = useContext(IdVerificationContext);
|
||||||
const panelSlug = 'review-requirements';
|
const panelSlug = 'review-requirements';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
@@ -41,7 +42,7 @@ const ReviewRequirementsPanel = (props) => {
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
support: (
|
support: (
|
||||||
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
|
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
|
||||||
{props.intl.formatMessage(messages['id.verification.support'])}
|
{intl.formatMessage(messages['id.verification.support'])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -54,17 +55,17 @@ const ReviewRequirementsPanel = (props) => {
|
|||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.requirements.title'])}
|
title={intl.formatMessage(messages['id.verification.requirements.title'])}
|
||||||
focusOnMount={false}
|
focusOnMount={false}
|
||||||
>
|
>
|
||||||
{renderManagedProfileMessage()}
|
{renderManagedProfileMessage()}
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.requirements.description'])}
|
{intl.formatMessage(messages['id.verification.requirements.description'])}
|
||||||
</p>
|
</p>
|
||||||
<div className="card mb-4 shadow accent border-warning">
|
<div className="card mb-4 shadow accent border-warning">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
{props.intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
|
{intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
|
||||||
</h6>
|
</h6>
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -72,7 +73,7 @@ const ReviewRequirementsPanel = (props) => {
|
|||||||
defaultMessage="You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click {allow}."
|
defaultMessage="You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click {allow}."
|
||||||
description="Text explaining that the user needs access to a camera."
|
description="Text explaining that the user needs access to a camera."
|
||||||
values={{
|
values={{
|
||||||
allow: <strong>{props.intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>,
|
allow: <strong>{intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
@@ -81,37 +82,37 @@ const ReviewRequirementsPanel = (props) => {
|
|||||||
<div className="card mb-4 shadow accent border-warning">
|
<div className="card mb-4 shadow accent border-warning">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
{props.intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
|
{intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
|
||||||
</h6>
|
</h6>
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
{props.intl.formatMessage(messages['id.verification.requirements.card.id.text'])}
|
{intl.formatMessage(messages['id.verification.requirements.card.id.text'])}
|
||||||
<img
|
<img
|
||||||
src={exampleCard}
|
src={exampleCard}
|
||||||
alt={props.intl.formatMessage(messages['id.verification.example.card.alt'])}
|
alt={intl.formatMessage(messages['id.verification.example.card.alt'])}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 aria-level="2" className="mb-3">
|
<h4 aria-level="2" className="mb-3">
|
||||||
{props.intl.formatMessage(messages['id.verification.privacy.title'])}
|
{intl.formatMessage(messages['id.verification.privacy.title'])}
|
||||||
</h4>
|
</h4>
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.need.photo.question'],
|
messages['id.verification.privacy.need.photo.question'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
</h6>
|
</h6>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
|
{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
|
||||||
</p>
|
</p>
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.do.with.photo.question'],
|
messages['id.verification.privacy.do.with.photo.question'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
</h6>
|
</h6>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.privacy.do.with.photo.answer'],
|
messages['id.verification.privacy.do.with.photo.answer'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
@@ -119,15 +120,11 @@ const ReviewRequirementsPanel = (props) => {
|
|||||||
|
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ReviewRequirementsPanel.propTypes = {
|
export default ReviewRequirementsPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ReviewRequirementsPanel);
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useRedirect } from '../../hooks';
|
import { useRedirect } from '../../hooks';
|
||||||
|
|
||||||
@@ -10,10 +10,11 @@ import messages from '../IdVerification.messages';
|
|||||||
|
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
|
|
||||||
const SubmittedPanel = (props) => {
|
const SubmittedPanel = () => {
|
||||||
const { userId } = useContext(IdVerificationContext);
|
const { userId } = useContext(IdVerificationContext);
|
||||||
const { location: returnUrl, text: returnText } = useRedirect();
|
const { location: returnUrl, text: returnText } = useRedirect();
|
||||||
const panelSlug = 'submitted';
|
const panelSlug = 'submitted';
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendTrackEvent('edx.id_verification.submitted', {
|
sendTrackEvent('edx.id_verification.submitted', {
|
||||||
@@ -25,24 +26,20 @@ const SubmittedPanel = (props) => {
|
|||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.submitted.title'])}
|
title={intl.formatMessage(messages['id.verification.submitted.title'])}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.submitted.text'])}
|
{intl.formatMessage(messages['id.verification.submitted.text'])}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}
|
href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}
|
||||||
data-testid="return-button"
|
data-testid="return-button"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages[returnText])}
|
{intl.formatMessage(messages[returnText])}
|
||||||
</a>
|
</a>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SubmittedPanel.propTypes = {
|
export default SubmittedPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(SubmittedPanel);
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
import { useState, useContext, useEffect } from 'react';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import {
|
import {
|
||||||
Alert, Hyperlink, Form, Button, Spinner,
|
Alert, Hyperlink, Form, Button, Spinner,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { submitIdVerification } from '../data/service';
|
import { submitIdVerification } from '../data/service';
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
@@ -16,7 +16,8 @@ import messages from '../IdVerification.messages';
|
|||||||
import CameraHelpWithUpload from '../CameraHelpWithUpload';
|
import CameraHelpWithUpload from '../CameraHelpWithUpload';
|
||||||
import SupportedMediaTypes from '../SupportedMediaTypes';
|
import SupportedMediaTypes from '../SupportedMediaTypes';
|
||||||
|
|
||||||
const SummaryPanel = (props) => {
|
const SummaryPanel = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const panelSlug = 'summary';
|
const panelSlug = 'summary';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
const {
|
const {
|
||||||
@@ -51,7 +52,7 @@ const SummaryPanel = (props) => {
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
support: (
|
support: (
|
||||||
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
|
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
|
||||||
{props.intl.formatMessage(messages['id.verification.support'])}
|
{intl.formatMessage(messages['id.verification.support'])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -90,12 +91,11 @@ const SummaryPanel = (props) => {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
title="Confirmation"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
data-testid="submit-button"
|
data-testid="submit-button"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['id.verification.review.confirm'])}
|
{intl.formatMessage(messages['id.verification.review.confirm'])}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -103,18 +103,18 @@ const SummaryPanel = (props) => {
|
|||||||
function getError() {
|
function getError() {
|
||||||
if (submissionError.status === 400) {
|
if (submissionError.status === 400) {
|
||||||
if (submissionError.message.includes('face_image')) {
|
if (submissionError.message.includes('face_image')) {
|
||||||
return props.intl.formatMessage(messages['id.verification.submission.alert.error.face']);
|
return intl.formatMessage(messages['id.verification.submission.alert.error.face']);
|
||||||
}
|
}
|
||||||
if (submissionError.message.includes('Photo ID image')) {
|
if (submissionError.message.includes('Photo ID image')) {
|
||||||
return props.intl.formatMessage(messages['id.verification.submission.alert.error.id']);
|
return intl.formatMessage(messages['id.verification.submission.alert.error.id']);
|
||||||
}
|
}
|
||||||
if (submissionError.message.includes('Name')) {
|
if (submissionError.message.includes('Name')) {
|
||||||
return props.intl.formatMessage(messages['id.verification.submission.alert.error.name']);
|
return intl.formatMessage(messages['id.verification.submission.alert.error.name']);
|
||||||
}
|
}
|
||||||
if (submissionError.message.includes('unsupported format')) {
|
if (submissionError.message.includes('unsupported format')) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])}
|
{intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])}
|
||||||
<SupportedMediaTypes />
|
<SupportedMediaTypes />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -131,7 +131,7 @@ const SummaryPanel = (props) => {
|
|||||||
values={{
|
values={{
|
||||||
support_link: (
|
support_link: (
|
||||||
<Alert.Link href="https://support.edx.org/hc/en-us">
|
<Alert.Link href="https://support.edx.org/hc/en-us">
|
||||||
{props.intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages['id.verification.review.error'],
|
messages['id.verification.review.error'],
|
||||||
{ siteName: getConfig().SITE_NAME },
|
{ siteName: getConfig().SITE_NAME },
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +145,7 @@ const SummaryPanel = (props) => {
|
|||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
title={props.intl.formatMessage(messages['id.verification.review.title'])}
|
title={intl.formatMessage(messages['id.verification.review.title'])}
|
||||||
>
|
>
|
||||||
{submissionError && (
|
{submissionError && (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -158,17 +158,17 @@ const SummaryPanel = (props) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.review.description'])}
|
{intl.formatMessage(messages['id.verification.review.description'])}
|
||||||
</p>
|
</p>
|
||||||
<div className="row mb-4">
|
<div className="row mb-4">
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
<label htmlFor="photo-of-face" className="font-weight-bold">
|
<label htmlFor="photo-of-face" className="font-weight-bold">
|
||||||
{props.intl.formatMessage(messages['id.verification.review.portrait.label'])}
|
{intl.formatMessage(messages['id.verification.review.portrait.label'])}
|
||||||
</label>
|
</label>
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
id="photo-of-face"
|
id="photo-of-face"
|
||||||
src={facePhotoFile}
|
src={facePhotoFile}
|
||||||
alt={props.intl.formatMessage(messages['id.verification.review.portrait.alt'])}
|
alt={intl.formatMessage(messages['id.verification.review.portrait.alt'])}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
@@ -176,17 +176,17 @@ const SummaryPanel = (props) => {
|
|||||||
state={{ fromSummary: true }}
|
state={{ fromSummary: true }}
|
||||||
data-testid="portrait-retake"
|
data-testid="portrait-retake"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['id.verification.review.portrait.retake'])}
|
{intl.formatMessage(messages['id.verification.review.portrait.retake'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
<label htmlFor="photo-of-id/edit" className="font-weight-bold">
|
<label htmlFor="photo-of-id/edit" className="font-weight-bold">
|
||||||
{props.intl.formatMessage(messages['id.verification.review.id.label'])}
|
{intl.formatMessage(messages['id.verification.review.id.label'])}
|
||||||
</label>
|
</label>
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
id="photo-of-id"
|
id="photo-of-id"
|
||||||
src={idPhotoFile}
|
src={idPhotoFile}
|
||||||
alt={props.intl.formatMessage(messages['id.verification.review.id.alt'])}
|
alt={intl.formatMessage(messages['id.verification.review.id.alt'])}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
@@ -194,14 +194,14 @@ const SummaryPanel = (props) => {
|
|||||||
state={{ fromSummary: true }}
|
state={{ fromSummary: true }}
|
||||||
data-testid="id-retake"
|
data-testid="id-retake"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['id.verification.review.id.retake'])}
|
{intl.formatMessage(messages['id.verification.review.id.retake'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CameraHelpWithUpload />
|
<CameraHelpWithUpload />
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="name-to-be-used" className="font-weight-bold">
|
<label htmlFor="name-to-be-used" className="font-weight-bold">
|
||||||
{props.intl.formatMessage(messages['id.verification.name.label'])}
|
{intl.formatMessage(messages['id.verification.name.label'])}
|
||||||
</label>
|
</label>
|
||||||
{renderManagedProfileMessage()}
|
{renderManagedProfileMessage()}
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
@@ -237,8 +237,4 @@ const SummaryPanel = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SummaryPanel.propTypes = {
|
export default SummaryPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(SummaryPanel);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
@@ -14,11 +14,12 @@ import ImageFileUpload from '../ImageFileUpload';
|
|||||||
import CollapsibleImageHelp from '../CollapsibleImageHelp';
|
import CollapsibleImageHelp from '../CollapsibleImageHelp';
|
||||||
import SupportedMediaTypes from '../SupportedMediaTypes';
|
import SupportedMediaTypes from '../SupportedMediaTypes';
|
||||||
|
|
||||||
const TakeIdPhotoPanel = (props) => {
|
const TakeIdPhotoPanel = () => {
|
||||||
const panelSlug = 'take-id-photo';
|
const panelSlug = 'take-id-photo';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
const { setIdPhotoFile, idPhotoFile, useCameraForId } = useContext(IdVerificationContext);
|
const { setIdPhotoFile, idPhotoFile, useCameraForId } = useContext(IdVerificationContext);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This prevents focus switching to the heading when taking a photo
|
// This prevents focus switching to the heading when taking a photo
|
||||||
@@ -30,31 +31,31 @@ const TakeIdPhotoPanel = (props) => {
|
|||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
focusOnMount={!mounted}
|
focusOnMount={!mounted}
|
||||||
title={useCameraForId
|
title={useCameraForId
|
||||||
? props.intl.formatMessage(messages['id.verification.id.photo.title.camera'])
|
? intl.formatMessage(messages['id.verification.id.photo.title.camera'])
|
||||||
: props.intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
|
: intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{idPhotoFile && !useCameraForId && (
|
{idPhotoFile && !useCameraForId && (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
src={idPhotoFile}
|
src={idPhotoFile}
|
||||||
alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
|
alt={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{useCameraForId ? (
|
{useCameraForId ? (
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])}
|
{intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])}
|
||||||
</p>
|
</p>
|
||||||
<Camera onImageCapture={setIdPhotoFile} isPortrait={false} />
|
<Camera onImageCapture={setIdPhotoFile} isPortrait={false} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginBottom: '1.25rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<p data-testid="upload-text">
|
<p data-testid="upload-text">
|
||||||
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
|
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
|
||||||
<SupportedMediaTypes />
|
<SupportedMediaTypes />
|
||||||
</p>
|
</p>
|
||||||
<ImageFileUpload onFileChange={setIdPhotoFile} intl={props.intl} />
|
<ImageFileUpload onFileChange={setIdPhotoFile} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -62,15 +63,11 @@ const TakeIdPhotoPanel = (props) => {
|
|||||||
<CollapsibleImageHelp />
|
<CollapsibleImageHelp />
|
||||||
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
|
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TakeIdPhotoPanel.propTypes = {
|
export default TakeIdPhotoPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(TakeIdPhotoPanel);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useNextPanelSlug } from '../routing-utilities';
|
import { useNextPanelSlug } from '../routing-utilities';
|
||||||
import BasePanel from './BasePanel';
|
import BasePanel from './BasePanel';
|
||||||
@@ -10,11 +10,12 @@ import IdVerificationContext from '../IdVerificationContext';
|
|||||||
|
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
const TakePortraitPhotoPanel = (props) => {
|
const TakePortraitPhotoPanel = () => {
|
||||||
const panelSlug = 'take-portrait-photo';
|
const panelSlug = 'take-portrait-photo';
|
||||||
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
const nextPanelSlug = useNextPanelSlug(panelSlug);
|
||||||
const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext);
|
const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This prevents focus switching to the heading when taking a photo
|
// This prevents focus switching to the heading when taking a photo
|
||||||
@@ -25,26 +26,22 @@ const TakePortraitPhotoPanel = (props) => {
|
|||||||
<BasePanel
|
<BasePanel
|
||||||
name={panelSlug}
|
name={panelSlug}
|
||||||
focusOnMount={!mounted}
|
focusOnMount={!mounted}
|
||||||
title={props.intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
|
title={intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
|
{intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
|
||||||
</p>
|
</p>
|
||||||
<Camera onImageCapture={setFacePhotoFile} isPortrait />
|
<Camera onImageCapture={setFacePhotoFile} isPortrait />
|
||||||
</div>
|
</div>
|
||||||
<CameraHelp isPortrait />
|
<CameraHelp isPortrait />
|
||||||
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
|
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
|
||||||
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
|
||||||
{props.intl.formatMessage(messages['id.verification.next'])}
|
{intl.formatMessage(messages['id.verification.next'])}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</BasePanel>
|
</BasePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TakePortraitPhotoPanel.propTypes = {
|
export default TakePortraitPhotoPanel;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(TakePortraitPhotoPanel);
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import messages from '../IdVerification.messages';
|
import messages from '../IdVerification.messages';
|
||||||
|
|
||||||
export const UnsupportedCameraDirectionsPanel = (props) => (
|
export const UnsupportedCameraDirectionsPanel = (props) => {
|
||||||
<>
|
const intl = useIntl();
|
||||||
{props.browserName === 'Chrome' && <span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>}
|
return (
|
||||||
<span> </span>
|
<>
|
||||||
<span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])}</span>
|
{props.browserName === 'Chrome' && <span>{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>}
|
||||||
</>
|
<span> </span>
|
||||||
);
|
<span>{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
UnsupportedCameraDirectionsPanel.propTypes = {
|
UnsupportedCameraDirectionsPanel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
browserName: PropTypes.string.isRequired,
|
browserName: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(UnsupportedCameraDirectionsPanel);
|
export default UnsupportedCameraDirectionsPanel;
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import {
|
|||||||
render, cleanup, act, screen,
|
render, cleanup, act, screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { ERROR_REASONS } from '../IdVerificationContext';
|
import { ERROR_REASONS } from '../IdVerificationContext';
|
||||||
import AccessBlocked from '../AccessBlocked';
|
import AccessBlocked from '../AccessBlocked';
|
||||||
|
|
||||||
const IntlAccessBlocked = injectIntl(AccessBlocked);
|
|
||||||
|
|
||||||
describe('AccessBlocked', () => {
|
describe('AccessBlocked', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
intl: {},
|
|
||||||
error: '',
|
error: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +24,7 @@ describe('AccessBlocked', () => {
|
|||||||
await act(async () => render((
|
await act(async () => render((
|
||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlAccessBlocked {...defaultProps} />
|
<AccessBlocked {...defaultProps} />
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
)));
|
)));
|
||||||
@@ -43,7 +40,7 @@ describe('AccessBlocked', () => {
|
|||||||
await act(async () => render((
|
await act(async () => render((
|
||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlAccessBlocked {...defaultProps} />
|
<AccessBlocked {...defaultProps} />
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
)));
|
)));
|
||||||
@@ -59,7 +56,7 @@ describe('AccessBlocked', () => {
|
|||||||
await act(async () => render((
|
await act(async () => render((
|
||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlAccessBlocked {...defaultProps} />
|
<AccessBlocked {...defaultProps} />
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
render, cleanup, screen, act, fireEvent,
|
render, cleanup, screen, act, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import CameraPhoto from 'jslib-html5-camera-photo';
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import * as blazeface from '@tensorflow-models/blazeface';
|
import * as blazeface from '@tensorflow-models/blazeface';
|
||||||
import * as analytics from '@edx/frontend-platform/analytics';
|
import * as analytics from '@edx/frontend-platform/analytics';
|
||||||
@@ -181,4 +182,99 @@ describe('SubmittedPanel', () => {
|
|||||||
await fireEvent.click(checkbox);
|
await fireEvent.click(checkbox);
|
||||||
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_disabled');
|
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_disabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Camera getSizeFactor method', () => {
|
||||||
|
let mockGetDataUri;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGetDataUri = jest.fn().mockReturnValue('data:image/jpeg;base64,test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales down large resolutions to stay under 10MB limit', async () => {
|
||||||
|
const currentSettings = { width: 4000, height: 3000 };
|
||||||
|
|
||||||
|
CameraPhoto.mockImplementation(() => ({
|
||||||
|
startCamera: jest.fn(),
|
||||||
|
stopCamera: jest.fn(),
|
||||||
|
getDataUri: mockGetDataUri,
|
||||||
|
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => render((
|
||||||
|
<Router>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
|
<Camera {...defaultProps} />
|
||||||
|
</IdVerificationContext.Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
</Router>
|
||||||
|
)));
|
||||||
|
|
||||||
|
const button = await screen.findByRole('button', { name: /take photo/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// For large resolution: size = 4000 * 3000 * 3 = 36,000,000 bytes
|
||||||
|
// Ratio = 9,999,999 / 36,000,000 ≈ 0.278
|
||||||
|
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
sizeFactor: expect.closeTo(0.278, 2),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales up 640x480 resolution to improve quality', async () => {
|
||||||
|
const currentSettings = { width: 640, height: 480 };
|
||||||
|
|
||||||
|
CameraPhoto.mockImplementation(() => ({
|
||||||
|
startCamera: jest.fn(),
|
||||||
|
stopCamera: jest.fn(),
|
||||||
|
getDataUri: mockGetDataUri,
|
||||||
|
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => render((
|
||||||
|
<Router>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
|
<Camera {...defaultProps} />
|
||||||
|
</IdVerificationContext.Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
</Router>
|
||||||
|
)));
|
||||||
|
|
||||||
|
const button = await screen.findByRole('button', { name: /take photo/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
sizeFactor: 2,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains original size for medium resolutions', async () => {
|
||||||
|
const currentSettings = { width: 1280, height: 720 };
|
||||||
|
|
||||||
|
CameraPhoto.mockImplementation(() => ({
|
||||||
|
startCamera: jest.fn(),
|
||||||
|
stopCamera: jest.fn(),
|
||||||
|
getDataUri: mockGetDataUri,
|
||||||
|
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => render((
|
||||||
|
<Router>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
|
<Camera {...defaultProps} />
|
||||||
|
</IdVerificationContext.Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
</Router>
|
||||||
|
)));
|
||||||
|
|
||||||
|
const button = await screen.findByRole('button', { name: /take photo/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
sizeFactor: 1,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, screen, act,
|
render, cleanup, screen, act,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import * as analytics from '@edx/frontend-platform/analytics';
|
import * as analytics from '@edx/frontend-platform/analytics';
|
||||||
import IdVerificationContext from '../IdVerificationContext';
|
import IdVerificationContext from '../IdVerificationContext';
|
||||||
import CollapsibleImageHelp from '../CollapsibleImageHelp';
|
import CollapsibleImageHelp from '../CollapsibleImageHelp';
|
||||||
@@ -17,11 +16,7 @@ analytics.sendTrackEvent = jest.fn();
|
|||||||
|
|
||||||
window.HTMLMediaElement.prototype.play = () => {};
|
window.HTMLMediaElement.prototype.play = () => {};
|
||||||
|
|
||||||
const IntlCollapsible = injectIntl(CollapsibleImageHelp);
|
|
||||||
|
|
||||||
describe('CollapsibleImageHelpPanel', () => {
|
describe('CollapsibleImageHelpPanel', () => {
|
||||||
const defaultProps = { intl: {} };
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
useCameraForId: true,
|
useCameraForId: true,
|
||||||
setUseCameraForId: jest.fn(),
|
setUseCameraForId: jest.fn(),
|
||||||
@@ -36,7 +31,7 @@ describe('CollapsibleImageHelpPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlCollapsible {...defaultProps} />
|
<CollapsibleImageHelp />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -56,7 +51,7 @@ describe('CollapsibleImageHelpPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlCollapsible {...defaultProps} />
|
<CollapsibleImageHelp />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { MemoryRouter as Router } from 'react-router-dom';
|
import { MemoryRouter as Router } from 'react-router-dom';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import {
|
import {
|
||||||
render, act, screen, fireEvent,
|
render, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationPage from '../IdVerificationPage';
|
import IdVerificationPageSlot from '../../plugin-slots/IdVerificationPageSlot';
|
||||||
import * as selectors from '../data/selectors';
|
import * as selectors from '../data/selectors';
|
||||||
|
|
||||||
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
|
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
|
||||||
@@ -47,22 +46,18 @@ jest.mock('../panels/SubmittedPanel', () => function SubmittedPanelMock() {
|
|||||||
return <></>;
|
return <></>;
|
||||||
});
|
});
|
||||||
|
|
||||||
const IntlIdVerificationPage = injectIntl(IdVerificationPage);
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('IdVerificationPage', () => {
|
describe('IdVerificationPage', () => {
|
||||||
selectors.mockClear();
|
selectors.mockClear();
|
||||||
jest.spyOn(Storage.prototype, 'setItem');
|
jest.spyOn(Storage.prototype, 'setItem');
|
||||||
const store = mockStore();
|
const store = mockStore();
|
||||||
const props = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
it('decodes and stores course_id', async () => {
|
it('decodes and stores course_id', async () => {
|
||||||
await act(async () => render((
|
await act(async () => render((
|
||||||
<Router initialEntries={[`/?course_id=${encodeURIComponent('course-v1:edX+DemoX+Demo_Course')}`]}>
|
<Router initialEntries={[`/?course_id=${encodeURIComponent('course-v1:edX+DemoX+Demo_Course')}`]}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<IntlIdVerificationPage {...props} />
|
<IdVerificationPageSlot />
|
||||||
</Provider>
|
</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -78,7 +73,7 @@ describe('IdVerificationPage', () => {
|
|||||||
<Router initialEntries={['/?next=dashboard']}>
|
<Router initialEntries={['/?next=dashboard']}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<IntlIdVerificationPage {...props} />
|
<IdVerificationPageSlot />
|
||||||
</Provider>
|
</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -93,7 +88,7 @@ describe('IdVerificationPage', () => {
|
|||||||
<Router initialEntries={['/?next=dashboard']}>
|
<Router initialEntries={['/?next=dashboard']}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<IntlIdVerificationPage {...props} />
|
<IdVerificationPageSlot />
|
||||||
</Provider>
|
</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -107,7 +102,7 @@ describe('IdVerificationPage', () => {
|
|||||||
<Router initialEntries={['/?next=dashboard']}>
|
<Router initialEntries={['/?next=dashboard']}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<IntlIdVerificationPage {...props} />
|
<IdVerificationPageSlot />
|
||||||
</Provider>
|
</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import { VerifiedNameContext } from '../../VerifiedNameContext';
|
import { VerifiedNameContext } from '../../VerifiedNameContext';
|
||||||
import GetNameIdPanel from '../../panels/GetNameIdPanel';
|
import GetNameIdPanel from '../../panels/GetNameIdPanel';
|
||||||
@@ -13,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlGetNameIdPanel = injectIntl(GetNameIdPanel);
|
|
||||||
|
|
||||||
describe('GetNameIdPanel', () => {
|
describe('GetNameIdPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const IDVerificationContextValue = {
|
const IDVerificationContextValue = {
|
||||||
nameOnAccount: 'test',
|
nameOnAccount: 'test',
|
||||||
userId: 3,
|
userId: 3,
|
||||||
@@ -37,7 +30,7 @@ describe('GetNameIdPanel', () => {
|
|||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
|
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
|
||||||
<IdVerificationContext.Provider value={idVerificationContextValue}>
|
<IdVerificationContext.Provider value={idVerificationContextValue}>
|
||||||
<IntlGetNameIdPanel {...defaultProps} />
|
<GetNameIdPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</VerifiedNameContext.Provider>
|
</VerifiedNameContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import IdContextPanel from '../../panels/IdContextPanel';
|
import IdContextPanel from '../../panels/IdContextPanel';
|
||||||
|
|
||||||
@@ -12,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlIdContextPanel = injectIntl(IdContextPanel);
|
|
||||||
|
|
||||||
describe('IdContextPanel', () => {
|
describe('IdContextPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
facePhotoFile: 'test.jpg',
|
facePhotoFile: 'test.jpg',
|
||||||
reachedSummary: false,
|
reachedSummary: false,
|
||||||
@@ -33,7 +27,7 @@ describe('IdContextPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlIdContextPanel {...defaultProps} />
|
<IdContextPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -49,7 +43,7 @@ describe('IdContextPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlIdContextPanel {...defaultProps} />
|
<IdContextPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel';
|
import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
|
|
||||||
@@ -12,13 +11,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlPortraitPhotoContextPanel = injectIntl(PortraitPhotoContextPanel);
|
|
||||||
|
|
||||||
describe('PortraitPhotoContextPanel', () => {
|
describe('PortraitPhotoContextPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = { reachedSummary: false };
|
const contextValue = { reachedSummary: false };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -30,7 +23,7 @@ describe('PortraitPhotoContextPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlPortraitPhotoContextPanel {...defaultProps} />
|
<PortraitPhotoContextPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -46,7 +39,7 @@ describe('PortraitPhotoContextPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlPortraitPhotoContextPanel {...defaultProps} />
|
<PortraitPhotoContextPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
render, screen, cleanup, act, fireEvent,
|
render, screen, cleanup, act, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import RequestCameraAccessPanel from '../../panels/RequestCameraAccessPanel';
|
import RequestCameraAccessPanel from '../../panels/RequestCameraAccessPanel';
|
||||||
|
|
||||||
@@ -15,13 +15,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
|
|
||||||
jest.mock('bowser');
|
jest.mock('bowser');
|
||||||
|
|
||||||
const IntlRequestCameraAccessPanel = injectIntl(RequestCameraAccessPanel);
|
|
||||||
|
|
||||||
describe('RequestCameraAccessPanel', () => {
|
describe('RequestCameraAccessPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
reachedSummary: false,
|
reachedSummary: false,
|
||||||
tryGetUserMedia: jest.fn(),
|
tryGetUserMedia: jest.fn(),
|
||||||
@@ -38,7 +32,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -54,7 +48,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -73,7 +67,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -89,7 +83,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -106,7 +100,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -123,7 +117,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -139,7 +133,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -155,7 +149,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -171,7 +165,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -188,7 +182,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -205,7 +199,7 @@ describe('RequestCameraAccessPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlRequestCameraAccessPanel {...defaultProps} />
|
<RequestCameraAccessPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import ReviewRequirementsPanel from '../../panels/ReviewRequirementsPanel';
|
import ReviewRequirementsPanel from '../../panels/ReviewRequirementsPanel';
|
||||||
|
|
||||||
@@ -12,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlReviewRequirementsPanel = injectIntl(ReviewRequirementsPanel);
|
|
||||||
|
|
||||||
describe('ReviewRequirementsPanel', () => {
|
describe('ReviewRequirementsPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const context = {};
|
const context = {};
|
||||||
|
|
||||||
const getPanel = async () => {
|
const getPanel = async () => {
|
||||||
@@ -26,7 +20,7 @@ describe('ReviewRequirementsPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={context}>
|
<IdVerificationContext.Provider value={context}>
|
||||||
<IntlReviewRequirementsPanel {...defaultProps} />
|
<ReviewRequirementsPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen,
|
render, cleanup, act, screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import SubmittedPanel from '../../panels/SubmittedPanel';
|
import SubmittedPanel from '../../panels/SubmittedPanel';
|
||||||
|
|
||||||
@@ -12,13 +11,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const IntlSubmittedPanel = injectIntl(SubmittedPanel);
|
|
||||||
|
|
||||||
describe('SubmittedPanel', () => {
|
describe('SubmittedPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
facePhotoFile: 'test.jpg',
|
facePhotoFile: 'test.jpg',
|
||||||
idPhotoFile: 'test.jpg',
|
idPhotoFile: 'test.jpg',
|
||||||
@@ -43,7 +36,7 @@ describe('SubmittedPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlSubmittedPanel {...defaultProps} />
|
<SubmittedPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -59,7 +52,7 @@ describe('SubmittedPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlSubmittedPanel {...defaultProps} />
|
<SubmittedPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -75,7 +68,7 @@ describe('SubmittedPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlSubmittedPanel {...defaultProps} />
|
<SubmittedPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/* eslint-disable no-import-assign */
|
/* eslint-disable no-import-assign */
|
||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen, fireEvent, waitFor,
|
render, cleanup, act, screen, fireEvent, waitFor,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import '@edx/frontend-platform/analytics';
|
import '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import * as dataService from '../../data/service';
|
import * as dataService from '../../data/service';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import SummaryPanel from '../../panels/SummaryPanel';
|
import SummaryPanel from '../../panels/SummaryPanel';
|
||||||
@@ -18,13 +17,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
jest.mock('../../data/service');
|
jest.mock('../../data/service');
|
||||||
dataService.submitIdVerification = jest.fn().mockReturnValue({ success: true });
|
dataService.submitIdVerification = jest.fn().mockReturnValue({ success: true });
|
||||||
|
|
||||||
const IntlSummaryPanel = injectIntl(SummaryPanel);
|
|
||||||
|
|
||||||
describe('SummaryPanel', () => {
|
describe('SummaryPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const appContextValue = {
|
const appContextValue = {
|
||||||
facePhotoFile: 'test.jpg',
|
facePhotoFile: 'test.jpg',
|
||||||
idPhotoFile: 'test.jpg',
|
idPhotoFile: 'test.jpg',
|
||||||
@@ -42,7 +35,7 @@ describe('SummaryPanel', () => {
|
|||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
|
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
|
||||||
<IdVerificationContext.Provider value={appContextValue}>
|
<IdVerificationContext.Provider value={appContextValue}>
|
||||||
<IntlSummaryPanel {...defaultProps} />
|
<SummaryPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</VerifiedNameContext.Provider>
|
</VerifiedNameContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import TakeIdPhotoPanel from '../../panels/TakeIdPhotoPanel';
|
import TakeIdPhotoPanel from '../../panels/TakeIdPhotoPanel';
|
||||||
|
import messages from '../../IdVerification.messages';
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||||
sendTrackEvent: jest.fn(),
|
sendTrackEvent: jest.fn(),
|
||||||
@@ -13,13 +13,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
|
|||||||
|
|
||||||
jest.mock('../../Camera');
|
jest.mock('../../Camera');
|
||||||
|
|
||||||
const IntlTakeIdPhotoPanel = injectIntl(TakeIdPhotoPanel);
|
|
||||||
|
|
||||||
describe('TakeIdPhotoPanel', () => {
|
describe('TakeIdPhotoPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
facePhotoFile: 'test.jpg',
|
facePhotoFile: 'test.jpg',
|
||||||
idPhotoFile: null,
|
idPhotoFile: null,
|
||||||
@@ -37,7 +31,7 @@ describe('TakeIdPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakeIdPhotoPanel {...defaultProps} />
|
<TakeIdPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -52,7 +46,7 @@ describe('TakeIdPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakeIdPhotoPanel {...defaultProps} />
|
<TakeIdPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -70,7 +64,7 @@ describe('TakeIdPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakeIdPhotoPanel {...defaultProps} />
|
<TakeIdPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -85,7 +79,7 @@ describe('TakeIdPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakeIdPhotoPanel {...defaultProps} />
|
<TakeIdPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -98,4 +92,24 @@ describe('TakeIdPhotoPanel', () => {
|
|||||||
const text = await screen.findByTestId('upload-text');
|
const text = await screen.findByTestId('upload-text');
|
||||||
expect(text.textContent).toContain('Please upload a photo of your identification card');
|
expect(text.textContent).toContain('Please upload a photo of your identification card');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows correct text if useCameraForId', async () => {
|
||||||
|
contextValue.useCameraForId = true;
|
||||||
|
await act(async () => render((
|
||||||
|
<Router>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
|
<TakeIdPhotoPanel />
|
||||||
|
</IdVerificationContext.Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
</Router>
|
||||||
|
)));
|
||||||
|
|
||||||
|
// check that upload title and text are correct
|
||||||
|
const title = await screen.findByText(messages['id.verification.id.photo.title.camera'].defaultMessage);
|
||||||
|
expect(title).toBeVisible();
|
||||||
|
|
||||||
|
const text = await screen.findByText(messages['id.verification.id.photo.instructions.camera'].defaultMessage);
|
||||||
|
expect(text).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import React from 'react';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
render, cleanup, act, screen, fireEvent,
|
render, cleanup, act, screen, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import IdVerificationContext from '../../IdVerificationContext';
|
import IdVerificationContext from '../../IdVerificationContext';
|
||||||
import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel';
|
import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel';
|
||||||
|
|
||||||
@@ -16,13 +15,7 @@ jest.mock('../../Camera', () => function CameraMock() {
|
|||||||
return <></>;
|
return <></>;
|
||||||
});
|
});
|
||||||
|
|
||||||
const IntlTakePortraitPhotoPanel = injectIntl(TakePortraitPhotoPanel);
|
|
||||||
|
|
||||||
describe('TakePortraitPhotoPanel', () => {
|
describe('TakePortraitPhotoPanel', () => {
|
||||||
const defaultProps = {
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
facePhotoFile: null,
|
facePhotoFile: null,
|
||||||
idPhotoFile: null,
|
idPhotoFile: null,
|
||||||
@@ -39,7 +32,7 @@ describe('TakePortraitPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakePortraitPhotoPanel {...defaultProps} />
|
<TakePortraitPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -54,7 +47,7 @@ describe('TakePortraitPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakePortraitPhotoPanel {...defaultProps} />
|
<TakePortraitPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -73,7 +66,7 @@ describe('TakePortraitPhotoPanel', () => {
|
|||||||
<Router>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IdVerificationContext.Provider value={contextValue}>
|
<IdVerificationContext.Provider value={contextValue}>
|
||||||
<IntlTakePortraitPhotoPanel {...defaultProps} />
|
<TakePortraitPhotoPanel />
|
||||||
</IdVerificationContext.Provider>
|
</IdVerificationContext.Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { Route, Routes, Outlet } from 'react-router-dom';
|
import { Route, Routes, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import Header from '@edx/frontend-component-header';
|
import Header from '@edx/frontend-component-header';
|
||||||
import FooterSlot from '@openedx/frontend-slot-footer';
|
import { FooterSlot } from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
import configureStore from './data/configureStore';
|
import configureStore from './data/configureStore';
|
||||||
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
||||||
@@ -21,8 +21,6 @@ import messages from './i18n';
|
|||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import Head from './head/Head';
|
import Head from './head/Head';
|
||||||
import NotificationCourses from './notification-preferences/NotificationCourses';
|
|
||||||
import NotificationPreferences from './notification-preferences/NotificationPreferences';
|
|
||||||
|
|
||||||
const rootNode = createRoot(document.getElementById('root'));
|
const rootNode = createRoot(document.getElementById('root'));
|
||||||
subscribe(APP_READY, () => {
|
subscribe(APP_READY, () => {
|
||||||
@@ -39,10 +37,8 @@ subscribe(APP_READY, () => {
|
|||||||
</main>
|
</main>
|
||||||
<FooterSlot />
|
<FooterSlot />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Route path="/notifications/:courseId" element={<NotificationPreferences />} />
|
|
||||||
<Route path="/notifications" element={<NotificationCourses />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/id-verification/*"
|
path="/id-verification/*"
|
||||||
element={<IdVerificationPageSlot />}
|
element={<IdVerificationPageSlot />}
|
||||||
@@ -69,9 +65,11 @@ initialize({
|
|||||||
config: () => {
|
config: () => {
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
SUPPORT_URL: process.env.SUPPORT_URL,
|
SUPPORT_URL: process.env.SUPPORT_URL,
|
||||||
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false',
|
SHOW_PUSH_CHANNEL: process.env.SHOW_PUSH_CHANNEL || false,
|
||||||
|
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || false,
|
||||||
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
|
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
|
||||||
ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
|
ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'),
|
||||||
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
|
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
|
||||||
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
|
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
|
||||||
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK,
|
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||||
|
|
||||||
$fa-font-path: "~font-awesome/fonts";
|
$fa-font-path: "~font-awesome/fonts";
|
||||||
@import "~font-awesome/scss/font-awesome";
|
@import "~font-awesome/scss/font-awesome";
|
||||||
|
|
||||||
@import "~@edx/brand/paragon/fonts";
|
|
||||||
@import "~@edx/brand/paragon/variables";
|
|
||||||
@import "~@openedx/paragon/scss/core/core";
|
|
||||||
@import "~@edx/brand/paragon/overrides";
|
|
||||||
@import "~@edx/frontend-component-header/dist/index";
|
@import "~@edx/frontend-component-header/dist/index";
|
||||||
@import "~@edx/frontend-component-footer/dist/footer";
|
@import "~@edx/frontend-component-footer/dist/footer";
|
||||||
|
|
||||||
@@ -118,9 +116,9 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:active,
|
.dropdown-item:active,
|
||||||
.dropdown-item:focus,
|
.dropdown-item:focus,
|
||||||
.btn-tertiary:not(:disabled):not(.disabled).active {
|
.btn-tertiary:not(:disabled):not(.disabled).active {
|
||||||
background-color: $light-300 !important;
|
background-color: var(--pgn-color-light-300) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +129,20 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
.h-4\.5 {
|
.h-4\.5 {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-dropdown{
|
||||||
|
#course-dropdown-btn {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px !important;
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 10px !important;
|
||||||
|
border: 1px solid var(--pgn-color-light-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.usabilla_live_button_container {
|
.usabilla_live_button_container {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import EMAIL_CADENCE from './data/constants';
|
import { EMAIL_CADENCE_PREFERENCES, EMAIL_CADENCE } from './data/constants';
|
||||||
import { selectUpdatePreferencesStatus } from './data/selectors';
|
import { selectUpdatePreferencesStatus } from './data/selectors';
|
||||||
import { LOADING_STATUS } from '../constants';
|
import { LOADING_STATUS } from '../constants';
|
||||||
|
|
||||||
const EmailCadences = ({
|
const EmailCadences = ({
|
||||||
email, onToggle, emailCadence, notificationType,
|
email, onToggle, emailCadence, notificationType, disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, open, close] = useToggle(false);
|
||||||
@@ -26,9 +26,10 @@ const EmailCadences = ({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
ref={setTarget}
|
ref={setTarget}
|
||||||
|
data-testid="email-cadence-button"
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
disabled={!email || updatePreferencesStatus === LOADING_STATUS}
|
disabled={!email || updatePreferencesStatus === LOADING_STATUS || disabled}
|
||||||
size="sm"
|
size="sm"
|
||||||
iconAfter={isOpen ? ExpandLess : ExpandMore}
|
iconAfter={isOpen ? ExpandLess : ExpandMore}
|
||||||
className="border-light-300 justify-content-between ml-3.5 cadence-button"
|
className="border-light-300 justify-content-between ml-3.5 cadence-button"
|
||||||
@@ -44,16 +45,17 @@ const EmailCadences = ({
|
|||||||
className="bg-white shadow d-flex flex-column margin-left-2"
|
className="bg-white shadow d-flex flex-column margin-left-2"
|
||||||
data-testid="email-cadence-dropdown"
|
data-testid="email-cadence-dropdown"
|
||||||
>
|
>
|
||||||
{Object.values(EMAIL_CADENCE).map((cadence) => (
|
{Object.values(EMAIL_CADENCE_PREFERENCES).map((cadence) => (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
key={cadence}
|
key={cadence}
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
name="email_cadence"
|
name={EMAIL_CADENCE}
|
||||||
className="d-flex justify-content-start py-1.5 font-size-14 cadence-button"
|
className="d-flex justify-content-start py-1.5 font-size-14 cadence-button"
|
||||||
size="inline"
|
size="inline"
|
||||||
active={cadence === emailCadence}
|
active={cadence === emailCadence}
|
||||||
autoFocus={cadence === emailCadence}
|
autoFocus={cadence === emailCadence}
|
||||||
|
data-testid={`email-cadence-${cadence}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onToggle(event, notificationType);
|
onToggle(event, notificationType);
|
||||||
close();
|
close();
|
||||||
@@ -71,8 +73,9 @@ const EmailCadences = ({
|
|||||||
EmailCadences.propTypes = {
|
EmailCadences.propTypes = {
|
||||||
email: PropTypes.bool.isRequired,
|
email: PropTypes.bool.isRequired,
|
||||||
onToggle: PropTypes.func.isRequired,
|
onToggle: PropTypes.func.isRequired,
|
||||||
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE)).isRequired,
|
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE_PREFERENCES)).isRequired,
|
||||||
notificationType: PropTypes.string.isRequired,
|
notificationType: PropTypes.string.isRequired,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(EmailCadences);
|
export default React.memo(EmailCadences);
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { ArrowForwardIos } from '@openedx/paragon/icons';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import {
|
|
||||||
Button, Container, Icon, Spinner,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
|
|
||||||
import messages from './messages';
|
|
||||||
import { useFeedbackWrapper } from '../hooks';
|
|
||||||
import { fetchCourseList } from './data/thunks';
|
|
||||||
import { NotFoundPage } from '../account-settings';
|
|
||||||
import { IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants';
|
|
||||||
import { selectCourseList, selectCourseListStatus, selectPagination } from './data/selectors';
|
|
||||||
|
|
||||||
const NotificationCourses = ({ intl }) => {
|
|
||||||
useFeedbackWrapper();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const coursesList = useSelector(selectCourseList());
|
|
||||||
const courseListStatus = useSelector(selectCourseListStatus());
|
|
||||||
const { hasMore, currentPage } = useSelector(selectPagination());
|
|
||||||
|
|
||||||
const loadMore = useCallback((page = 1, pageSize = 10) => {
|
|
||||||
dispatch(fetchCourseList(page, pageSize));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (courseListStatus === IDLE_STATUS) { loadMore(); }
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (courseListStatus === SUCCESS_STATUS && coursesList.length === 0) {
|
|
||||||
return <NotFoundPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="md">
|
|
||||||
<h2 className="notification-heading mt-6 mb-5.5">
|
|
||||||
{intl.formatMessage(messages.notificationHeading)}
|
|
||||||
</h2>
|
|
||||||
<div data-testid="courses-list">
|
|
||||||
{coursesList.map(course => (
|
|
||||||
<Link
|
|
||||||
key={course.id}
|
|
||||||
to={`/notifications/${course.id}`}
|
|
||||||
className="text-decoration-none"
|
|
||||||
>
|
|
||||||
<div className="mb-4 d-flex text-gray-700">
|
|
||||||
<span className="ml-0 mr-auto">
|
|
||||||
{course.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto mr-0">
|
|
||||||
<Icon src={ArrowForwardIos} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{courseListStatus === LOADING_STATUS ? (
|
|
||||||
<div className="d-flex">
|
|
||||||
<Spinner
|
|
||||||
variant="primary"
|
|
||||||
animation="border"
|
|
||||||
className="mx-auto my-auto"
|
|
||||||
size="lg"
|
|
||||||
data-testid="loading-spinner"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : hasMore && (
|
|
||||||
<Button variant="primary" className="w-100 bg-primary-500" onClick={() => loadMore(currentPage + 1)}>
|
|
||||||
{intl.formatMessage(messages.loadMoreCourses)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
NotificationCourses.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(NotificationCourses);
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/* eslint-disable no-import-assign */
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import configureStore from 'redux-mock-store';
|
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
|
||||||
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { defaultState } from './data/reducers';
|
|
||||||
import NotificationCourses from './NotificationCourses';
|
|
||||||
import { LOADING_STATUS, SUCCESS_STATUS } from '../constants';
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth');
|
|
||||||
|
|
||||||
const courseList = [
|
|
||||||
{ id: 'course-id-1', name: 'Course Name 1' },
|
|
||||||
{ id: 'course-id-2', name: 'Course Name 2' },
|
|
||||||
{ id: 'course-id-3', name: 'Course Name 3' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const setupStore = (override = {}) => {
|
|
||||||
const storeState = defaultState;
|
|
||||||
storeState.courses = {
|
|
||||||
...storeState.courses,
|
|
||||||
...override,
|
|
||||||
};
|
|
||||||
const store = mockStore({
|
|
||||||
notificationPreferences: storeState,
|
|
||||||
});
|
|
||||||
return store;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComponent = (store = {}) => (
|
|
||||||
render(
|
|
||||||
<Router>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<Provider store={store}>
|
|
||||||
<NotificationCourses />
|
|
||||||
</Provider>
|
|
||||||
</IntlProvider>
|
|
||||||
</Router>,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Notification Courses', () => {
|
|
||||||
let store;
|
|
||||||
beforeEach(() => {
|
|
||||||
store = setupStore({
|
|
||||||
courses: courseList,
|
|
||||||
status: SUCCESS_STATUS,
|
|
||||||
pagination: {
|
|
||||||
count: 3,
|
|
||||||
currentPage: 1,
|
|
||||||
hasMore: false,
|
|
||||||
totalPages: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
|
||||||
patch: async () => ({
|
|
||||||
data: { status: 200 },
|
|
||||||
catch: () => {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
|
|
||||||
window.lightningjs = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks());
|
|
||||||
|
|
||||||
it('tests if all courses are available', async () => {
|
|
||||||
await renderComponent(store);
|
|
||||||
expect(screen.queryByTestId('courses-list').children).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('show spinner if api call is in progress', async () => {
|
|
||||||
store = setupStore({ status: LOADING_STATUS });
|
|
||||||
await renderComponent(store);
|
|
||||||
expect(screen.queryByTestId('loading-spinner')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('show not found page if course list is empty', async () => {
|
|
||||||
store = setupStore({ status: SUCCESS_STATUS, courses: [] });
|
|
||||||
await renderComponent(store);
|
|
||||||
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('show load more courses button when hasMore True', async () => {
|
|
||||||
store = setupStore({ status: SUCCESS_STATUS, pagination: { ...store.pagination, hasMore: true, totalPages: 2 } });
|
|
||||||
await renderComponent(store);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Load more courses')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,27 +11,22 @@ import { useIsOnMobile } from '../hooks';
|
|||||||
import NotificationTypes from './NotificationTypes';
|
import NotificationTypes from './NotificationTypes';
|
||||||
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
||||||
import NotificationPreferenceColumn from './NotificationPreferenceColumn';
|
import NotificationPreferenceColumn from './NotificationPreferenceColumn';
|
||||||
import { selectPreferenceAppToggleValue, selectSelectedCourseId, selectAppPreferences } from './data/selectors';
|
import { selectPreferenceAppToggleValue, selectAppPreferences } from './data/selectors';
|
||||||
|
|
||||||
const NotificationPreferenceApp = ({ appId }) => {
|
const NotificationPreferenceApp = ({ appId }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const courseId = useSelector(selectSelectedCourseId());
|
|
||||||
const appToggle = useSelector(selectPreferenceAppToggleValue(appId));
|
const appToggle = useSelector(selectPreferenceAppToggleValue(appId));
|
||||||
const appPreferences = useSelector(selectAppPreferences(appId));
|
const appPreferences = useSelector(selectAppPreferences(appId));
|
||||||
const mobileView = useIsOnMobile();
|
const mobileView = useIsOnMobile();
|
||||||
const NOTIFICATION_CHANNELS = notificationChannels();
|
const NOTIFICATION_CHANNELS = notificationChannels();
|
||||||
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
||||||
|
|
||||||
if (!courseId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!hideAppPreferences && (
|
!hideAppPreferences && (
|
||||||
<Collapsible.Advanced
|
<Collapsible.Advanced
|
||||||
open={appToggle}
|
open={appToggle}
|
||||||
data-testid={`${appId}-app`}
|
data-testid={`${appId}-app`}
|
||||||
className={classNames({ 'mb-5': !mobileView && appToggle })}
|
className={classNames({ 'mb-4.5': !mobileView && appToggle })}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
|
|||||||
@@ -13,35 +13,59 @@ import ToggleSwitch from './ToggleSwitch';
|
|||||||
import EmailCadences from './EmailCadences';
|
import EmailCadences from './EmailCadences';
|
||||||
import { LOADING_STATUS } from '../constants';
|
import { LOADING_STATUS } from '../constants';
|
||||||
import { updatePreferenceToggle } from './data/thunks';
|
import { updatePreferenceToggle } from './data/thunks';
|
||||||
import { selectAppPreferences, selectSelectedCourseId, selectUpdatePreferencesStatus } from './data/selectors';
|
import {
|
||||||
|
selectAppNonEditableChannels, selectAppPreferences,
|
||||||
|
selectUpdatePreferencesStatus,
|
||||||
|
} from './data/selectors';
|
||||||
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
||||||
|
import { EMAIL, EMAIL_CADENCE } from './data/constants';
|
||||||
|
|
||||||
const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const courseId = useSelector(selectSelectedCourseId());
|
|
||||||
const appPreferences = useSelector(selectAppPreferences(appId));
|
const appPreferences = useSelector(selectAppPreferences(appId));
|
||||||
const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus());
|
const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus());
|
||||||
|
const nonEditable = useSelector(selectAppNonEditableChannels(appId));
|
||||||
const mobileView = useIsOnMobile();
|
const mobileView = useIsOnMobile();
|
||||||
const NOTIFICATION_CHANNELS = Object.values(notificationChannels());
|
const NOTIFICATION_CHANNELS = Object.values(notificationChannels());
|
||||||
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
||||||
|
|
||||||
|
const getValue = useCallback((notificationChannel, innerText, checked) => {
|
||||||
|
if (notificationChannel === EMAIL_CADENCE) {
|
||||||
|
return innerText;
|
||||||
|
}
|
||||||
|
return checked;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getEmailCadence = useCallback((notificationChannel, innerText, emailCadence) => {
|
||||||
|
if (notificationChannel === EMAIL_CADENCE) {
|
||||||
|
return innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailCadence;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onToggle = useCallback((event, notificationType) => {
|
const onToggle = useCallback((event, notificationType) => {
|
||||||
const { name: notificationChannel } = event.target;
|
const { name: notificationChannel, checked, innerText } = event.target;
|
||||||
const value = notificationChannel === 'email_cadence' ? event.target.innerText : event.target.checked;
|
const appNotificationPreference = appPreferences.find(preference => preference.id === notificationType);
|
||||||
|
|
||||||
|
const value = getValue(notificationChannel, innerText, checked);
|
||||||
|
const emailCadence = getEmailCadence(
|
||||||
|
notificationChannel,
|
||||||
|
innerText,
|
||||||
|
appNotificationPreference.emailCadence,
|
||||||
|
);
|
||||||
|
|
||||||
dispatch(updatePreferenceToggle(
|
dispatch(updatePreferenceToggle(
|
||||||
courseId,
|
|
||||||
appId,
|
appId,
|
||||||
notificationType,
|
notificationType,
|
||||||
notificationChannel,
|
notificationChannel,
|
||||||
value,
|
value,
|
||||||
|
emailCadence,
|
||||||
));
|
));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [appPreferences, getValue, getEmailCadence, dispatch, appId]);
|
||||||
}, [appId]);
|
|
||||||
|
|
||||||
const renderPreference = (preference) => (
|
const renderPreference = (preference) => (
|
||||||
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
|
|
||||||
<div
|
<div
|
||||||
key={`${preference.id}-${channel}`}
|
key={`${preference.id}-${channel}`}
|
||||||
id={`${preference.id}-${channel}`}
|
id={`${preference.id}-${channel}`}
|
||||||
@@ -56,20 +80,20 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
|||||||
name={channel}
|
name={channel}
|
||||||
value={preference[channel]}
|
value={preference[channel]}
|
||||||
onChange={(event) => onToggle(event, preference.id)}
|
onChange={(event) => onToggle(event, preference.id)}
|
||||||
disabled={updatePreferencesStatus === LOADING_STATUS}
|
disabled={updatePreferencesStatus === LOADING_STATUS || nonEditable[preference.id]?.includes(channel)}
|
||||||
id={`${preference.id}-${channel}`}
|
id={`toggle-${preference.id}-${channel}`}
|
||||||
className="my-1"
|
className="my-1"
|
||||||
/>
|
/>
|
||||||
{channel === 'email' && (
|
{channel === EMAIL && (
|
||||||
<EmailCadences
|
<EmailCadences
|
||||||
email={preference.email}
|
email={preference.email}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
emailCadence={preference.emailCadence}
|
emailCadence={preference.emailCadence}
|
||||||
notificationType={preference.id}
|
notificationType={preference.id}
|
||||||
|
disabled={nonEditable[preference.id]?.includes(channel)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,40 +1,29 @@
|
|||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { Link, useParams } from 'react-router-dom';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { ArrowBack } from '@openedx/paragon/icons';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import { Spinner, NavItem } from '@openedx/paragon';
|
||||||
Container, Hyperlink, Icon, Spinner, NavItem,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
|
|
||||||
import { useIsOnMobile } from '../hooks';
|
import { useIsOnMobile } from '../hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { NotFoundPage } from '../account-settings';
|
|
||||||
import NotificationPreferenceApp from './NotificationPreferenceApp';
|
import NotificationPreferenceApp from './NotificationPreferenceApp';
|
||||||
import { fetchCourseList, fetchCourseNotificationPreferences } from './data/thunks';
|
import { fetchNotificationPreferences } from './data/thunks';
|
||||||
|
import { LOADING_STATUS } from '../constants';
|
||||||
import {
|
import {
|
||||||
FAILURE_STATUS, IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS,
|
selectNotificationPreferencesStatus, selectPreferenceAppsId,
|
||||||
} from '../constants';
|
|
||||||
import {
|
|
||||||
selectCourse, selectCourseList, selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId,
|
|
||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
import { notificationChannels } from './data/utils';
|
import { notificationChannels } from './data/utils';
|
||||||
|
|
||||||
const NotificationPreferences = () => {
|
const NotificationPreferences = () => {
|
||||||
const { courseId } = useParams();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const courseStatus = useSelector(selectCourseListStatus());
|
|
||||||
const coursesList = useSelector(selectCourseList());
|
|
||||||
const course = useSelector(selectCourse(courseId));
|
|
||||||
const notificationStatus = useSelector(selectNotificationPreferencesStatus());
|
const notificationStatus = useSelector(selectNotificationPreferencesStatus());
|
||||||
const preferenceAppsIds = useSelector(selectPreferenceAppsId());
|
const preferenceAppsIds = useSelector(selectPreferenceAppsId());
|
||||||
const mobileView = useIsOnMobile();
|
const mobileView = useIsOnMobile();
|
||||||
const NOTIFICATION_CHANNELS = notificationChannels();
|
const NOTIFICATION_CHANNELS = notificationChannels();
|
||||||
const isLoading = notificationStatus === LOADING_STATUS || courseStatus === LOADING_STATUS;
|
const isLoading = notificationStatus === LOADING_STATUS;
|
||||||
|
|
||||||
const preferencesList = useMemo(() => (
|
const preferencesList = useMemo(() => (
|
||||||
preferenceAppsIds.map(appId => (
|
preferenceAppsIds.map(appId => (
|
||||||
@@ -43,46 +32,16 @@ const NotificationPreferences = () => {
|
|||||||
), [preferenceAppsIds]);
|
), [preferenceAppsIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ([IDLE_STATUS, FAILURE_STATUS].includes(courseStatus)) {
|
dispatch(fetchNotificationPreferences());
|
||||||
dispatch(fetchCourseList());
|
}, [dispatch]);
|
||||||
}
|
|
||||||
dispatch(fetchCourseNotificationPreferences(courseId));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [courseId]);
|
|
||||||
|
|
||||||
if (
|
if (preferenceAppsIds.length === 0) {
|
||||||
(courseStatus === SUCCESS_STATUS && coursesList.length === 0)
|
return null;
|
||||||
|| (notificationStatus === FAILURE_STATUS && coursesList.length !== 0)
|
|
||||||
) {
|
|
||||||
return <NotFoundPage />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" className="notification-preferences">
|
<div className="h-100">
|
||||||
<h2 className="notification-heading mt-6 mb-4.5">
|
{!mobileView && !isLoading && (
|
||||||
{intl.formatMessage(messages.notificationHeading)}
|
|
||||||
</h2>
|
|
||||||
<div className="mb-6 text-gray-700 font-size-14 margin-bottom-32">
|
|
||||||
{intl.formatMessage(messages.notificationPreferenceGuideBody)}
|
|
||||||
<Hyperlink
|
|
||||||
destination="https://docs.openedx.org/en/latest/learners/sfd_notifications/index.html"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-decoration-underline ml-1"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
|
|
||||||
</Hyperlink>
|
|
||||||
</div>
|
|
||||||
<div className="h-100">
|
|
||||||
<div className="d-flex mb-5">
|
|
||||||
<Link to="/notifications">
|
|
||||||
<Icon className="text-primary-500" src={ArrowBack} />
|
|
||||||
</Link>
|
|
||||||
<span className="notification-course-title ml-auto mr-auto text-primary-500">
|
|
||||||
{course?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!mobileView && !isLoading && (
|
|
||||||
<div className="d-flex flex-row justify-content-between float-right">
|
<div className="d-flex flex-row justify-content-between float-right">
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{Object.values(NOTIFICATION_CHANNELS).map((channel) => (
|
{Object.values(NOTIFICATION_CHANNELS).map((channel) => (
|
||||||
@@ -103,21 +62,20 @@ const NotificationPreferences = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{preferencesList}
|
{preferencesList}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
<Spinner
|
<Spinner
|
||||||
variant="primary"
|
variant="primary"
|
||||||
animation="border"
|
animation="border"
|
||||||
className="mx-auto my-auto"
|
className="mx-auto my-auto"
|
||||||
size="lg"
|
size="lg"
|
||||||
data-testid="loading-spinner"
|
data-testid="loading-spinner"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { Provider } from 'react-redux';
|
|||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { setConfig, mergeConfig } from '@edx/frontend-platform';
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
import * as auth from '@edx/frontend-platform/auth';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import { defaultState } from './data/reducers';
|
import { defaultState } from './data/reducers';
|
||||||
import NotificationPreferences from './NotificationPreferences';
|
import NotificationPreferences from './NotificationPreferences';
|
||||||
import { FAILURE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants';
|
import { LOADING_STATUS, SUCCESS_STATUS } from '../constants';
|
||||||
|
import {
|
||||||
|
getNotificationPreferences,
|
||||||
|
postPreferenceToggle,
|
||||||
|
} from './data/service';
|
||||||
|
|
||||||
const courseId = 'selected-course-id';
|
const courseId = 'selected-course-id';
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ const defaultPreferences = {
|
|||||||
appId: 'coursework',
|
appId: 'coursework',
|
||||||
web: false,
|
web: false,
|
||||||
push: false,
|
push: false,
|
||||||
email: false,
|
email: true,
|
||||||
coreNotificationTypes: [],
|
coreNotificationTypes: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,7 +71,7 @@ const defaultPreferences = {
|
|||||||
nonEditable: {
|
nonEditable: {
|
||||||
discussion: {
|
discussion: {
|
||||||
core: [
|
core: [
|
||||||
'web',
|
'web', 'email',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,6 +82,7 @@ const setupStore = (override = {}) => {
|
|||||||
storeState.courses = {
|
storeState.courses = {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
courses: [
|
courses: [
|
||||||
|
{ id: '', name: 'Account' },
|
||||||
{ id: 'selected-course-id', name: 'Selected Course' },
|
{ id: 'selected-course-id', name: 'Selected Course' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -104,10 +110,14 @@ describe('Notification Preferences', () => {
|
|||||||
let store;
|
let store;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mergeConfig({
|
||||||
|
SHOW_EMAIL_CHANNEL: '',
|
||||||
|
SHOW_PUSH_CHANNEL: '',
|
||||||
|
}, 'App loadConfig override handler');
|
||||||
|
|
||||||
store = setupStore({
|
store = setupStore({
|
||||||
...defaultPreferences,
|
...defaultPreferences,
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
selectedCourse: courseId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||||
@@ -138,17 +148,126 @@ describe('Notification Preferences', () => {
|
|||||||
expect(screen.queryAllByTestId('notification-preference')).toHaveLength(4);
|
expect(screen.queryAllByTestId('notification-preference')).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update preference on click', async () => {
|
it('update account preference on click', async () => {
|
||||||
const wrapper = await render(notificationPreferences(store));
|
store = setupStore({
|
||||||
const element = wrapper.container.querySelector('#core-web');
|
...defaultPreferences,
|
||||||
expect(element).not.toBeChecked();
|
status: SUCCESS_STATUS,
|
||||||
|
});
|
||||||
|
await render(notificationPreferences(store));
|
||||||
|
const element = screen.getByTestId('toggle-core-web');
|
||||||
await fireEvent.click(element);
|
await fireEvent.click(element);
|
||||||
expect(mockDispatch).toHaveBeenCalled();
|
expect(mockDispatch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('show not found page if invalid course id is entered in url', async () => {
|
it('test non editable', async () => {
|
||||||
store = setupStore({ status: FAILURE_STATUS, selectedCourse: 'invalid-course-id' });
|
setConfig({
|
||||||
|
SHOW_EMAIL_CHANNEL: 'true',
|
||||||
|
});
|
||||||
|
store = setupStore({
|
||||||
|
...defaultPreferences,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
selectedCourse: '',
|
||||||
|
});
|
||||||
await render(notificationPreferences(store));
|
await render(notificationPreferences(store));
|
||||||
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
|
expect(screen.getByTestId('toggle-core-web')).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('toggle-core-email')).toBeDisabled();
|
||||||
|
expect(screen.getAllByTestId('email-cadence-button')[0]).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('toggle-newGrade-web')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render push channel when SHOW_PUSH_CHANNEL is false', async () => {
|
||||||
|
setConfig({
|
||||||
|
SHOW_PUSH_CHANNEL: '',
|
||||||
|
});
|
||||||
|
store = setupStore({
|
||||||
|
...defaultPreferences,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
selectedCourse: '',
|
||||||
|
});
|
||||||
|
await render(notificationPreferences(store));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('toggle-core-push')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders push channel when SHOW_PUSH_CHANNEL is true', async () => {
|
||||||
|
setConfig({
|
||||||
|
SHOW_PUSH_CHANNEL: 'true',
|
||||||
|
});
|
||||||
|
store = setupStore({
|
||||||
|
...defaultPreferences,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
selectedCourse: '',
|
||||||
|
});
|
||||||
|
await render(notificationPreferences(store));
|
||||||
|
expect(screen.queryByTestId('toggle-core-push')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render email channel when SHOW_EMAIL_CHANNEL is false', async () => {
|
||||||
|
setConfig({
|
||||||
|
SHOW_EMAIL_CHANNEL: '',
|
||||||
|
});
|
||||||
|
store = setupStore({
|
||||||
|
...defaultPreferences,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
selectedCourse: '',
|
||||||
|
});
|
||||||
|
await render(notificationPreferences(store));
|
||||||
|
expect(screen.queryByTestId('toggle-core-email')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders email channel when SHOW_EMAIL_CHANNEL is true', async () => {
|
||||||
|
setConfig({
|
||||||
|
SHOW_EMAIL_CHANNEL: 'true',
|
||||||
|
});
|
||||||
|
store = setupStore({
|
||||||
|
...defaultPreferences,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
selectedCourse: '',
|
||||||
|
});
|
||||||
|
await render(notificationPreferences(store));
|
||||||
|
expect(screen.queryByTestId('toggle-core-email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification Preferences API v2 Logic', () => {
|
||||||
|
const LMS_BASE_URL = 'https://lms.example.com';
|
||||||
|
let mockHttpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockHttpClient = {
|
||||||
|
get: jest.fn().mockResolvedValue({ data: {} }),
|
||||||
|
put: jest.fn().mockResolvedValue({ data: {} }),
|
||||||
|
post: jest.fn().mockResolvedValue({ data: {} }),
|
||||||
|
patch: jest.fn().mockResolvedValue({ data: {} }),
|
||||||
|
};
|
||||||
|
auth.getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||||
|
|
||||||
|
setConfig({ LMS_BASE_URL });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNotificationPreferences', () => {
|
||||||
|
it('should call the v2 configurations URL', async () => {
|
||||||
|
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v3/configurations/`;
|
||||||
|
|
||||||
|
await getNotificationPreferences();
|
||||||
|
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expectedUrl);
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postPreferenceToggle', () => {
|
||||||
|
it('should call the v2 configurations URL with PUT method', async () => {
|
||||||
|
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v3/configurations/`;
|
||||||
|
const testArgs = ['app_name', 'notification_type', 'web', true, 'daily'];
|
||||||
|
|
||||||
|
await postPreferenceToggle(...testArgs);
|
||||||
|
|
||||||
|
expect(mockHttpClient.put).toHaveBeenCalledWith(expectedUrl, expect.any(Object));
|
||||||
|
expect(mockHttpClient.put).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHttpClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user