Compare commits
138 Commits
2u-main
...
release/ul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c78bf34ef | ||
|
|
36827914eb | ||
|
|
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 |
11
.env
11
.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=''
|
||||||
@@ -31,10 +32,8 @@ MARKETING_EMAILS_OPT_IN=''
|
|||||||
APP_ID=
|
APP_ID=
|
||||||
MFE_CONFIG_API_URL=
|
MFE_CONFIG_API_URL=
|
||||||
PASSWORD_RESET_SUPPORT_LINK=''
|
PASSWORD_RESET_SUPPORT_LINK=''
|
||||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT=''
|
|
||||||
ACCOUNT_BASICS_SUPPORT_URL=''
|
|
||||||
EMAIL_CONFIRMATION_SUPPORT_URL=''
|
|
||||||
CERTIFICATES_SUPPORT_URL=''
|
|
||||||
LEARNER_SUPPORT_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'
|
||||||
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
# Fallback in local style files
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -28,14 +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=''
|
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'
|
||||||
ACCOUNT_BASICS_SUPPORT_URL=''
|
|
||||||
EMAIL_CONFIRMATION_SUPPORT_URL=''
|
|
||||||
CERTIFICATES_SUPPORT_URL=''
|
|
||||||
LEARNER_SUPPORT_URL=''
|
|
||||||
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
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=''
|
||||||
@@ -32,3 +33,4 @@ 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='[]'
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -41,17 +41,6 @@ detect_changed_source_translations:
|
|||||||
# Checking for changed translations...
|
# Checking for changed translations...
|
||||||
git diff --exit-code $(i18n)
|
git diff --exit-code $(i18n)
|
||||||
|
|
||||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
|
||||||
push_translations:
|
|
||||||
# Pushing strings to Transifex...
|
|
||||||
tx push -s
|
|
||||||
# Fetching hashes from Transifex...
|
|
||||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
|
||||||
# Writing out comments to file...
|
|
||||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
|
||||||
# Pushing comments to Transifex...
|
|
||||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
|
||||||
|
|
||||||
pull_translations:
|
pull_translations:
|
||||||
rm -rf src/i18n/messages
|
rm -rf src/i18n/messages
|
||||||
mkdir src/i18n/messages
|
mkdir src/i18n/messages
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
||||||
13948
package-lock.json
generated
13948
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
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.12.1",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"core-js": "3.41.0",
|
"core-js": "3.46.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,7 +61,7 @@
|
|||||||
"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.14.0",
|
||||||
@@ -85,12 +85,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.5.0",
|
"@edx/browserslist-config": "1.5.0",
|
||||||
"@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';
|
||||||
|
|
||||||
@@ -50,9 +50,10 @@ import {
|
|||||||
FIELD_LABELS,
|
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 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) {
|
||||||
@@ -75,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', {
|
||||||
@@ -732,6 +733,8 @@ 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-6" 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">
|
||||||
@@ -852,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,7 +948,7 @@ 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,
|
||||||
})),
|
})),
|
||||||
@@ -1010,7 +1013,7 @@ AccountSettingsPage.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
||||||
fetchCourseList,
|
fetchNotificationPreferences,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
saveMultipleSettings,
|
saveMultipleSettings,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
@@ -227,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 = {
|
||||||
@@ -249,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,19 +1,17 @@
|
|||||||
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 } from '@openedx/paragon';
|
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
|
||||||
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 messages from './AccountSettingsPage.messages';
|
import messages from './AccountSettingsPage.messages';
|
||||||
|
|
||||||
const JumpNav = ({
|
const JumpNav = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) => {
|
|
||||||
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
||||||
|
|
||||||
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',
|
||||||
@@ -71,8 +69,4 @@ const JumpNav = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -135,7 +135,3 @@ export function getStatesList(country) {
|
|||||||
export const FIELD_LABELS = {
|
export const FIELD_LABELS = {
|
||||||
COUNTRY: 'country',
|
COUNTRY: 'country',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DECLINED = 'declined';
|
|
||||||
export const SELF_DESCRIBE = 'self-describe';
|
|
||||||
export const OTHER = 'other';
|
|
||||||
|
|||||||
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: 'twitter', 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_twitter).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_twitter: 'http://t' };
|
||||||
|
const apiResponse = {
|
||||||
|
username: 'testuser',
|
||||||
|
social_links: [{ platform: 'twitter', 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: 'twitter', social_link: 'http://t' }] }),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(result.social_link_twitter).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,6 +78,7 @@ 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}>Cancel</Button>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class DeleteAccount extends React.Component {
|
|||||||
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
|
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canDelete = isVerifiedAccount && !hasLinkedTPA;
|
const canDelete = isVerifiedAccount && !hasLinkedTPA;
|
||||||
const supportArticleUrl = getConfig().SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT;
|
const supportArticleUrl = process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT;
|
||||||
|
|
||||||
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
|
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
|
||||||
// to allow edx.org to fulfill its business requirements.
|
// to allow edx.org to fulfill its business requirements.
|
||||||
@@ -102,7 +102,7 @@ export class DeleteAccount extends React.Component {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Hyperlink destination={getConfig().ACCOUNT_BASICS_SUPPORT_URL}>
|
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</p>
|
</p>
|
||||||
@@ -118,7 +118,7 @@ export class DeleteAccount extends React.Component {
|
|||||||
{isVerifiedAccount ? null : (
|
{isVerifiedAccount ? null : (
|
||||||
<BeforeProceedingBanner
|
<BeforeProceedingBanner
|
||||||
instructionMessageId={optInInstructionMessageId}
|
instructionMessageId={optInInstructionMessageId}
|
||||||
supportArticleUrl={getConfig().EMAIL_CONFIRMATION_SUPPORT_URL}
|
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasLinkedTPA ? (
|
{hasLinkedTPA ? (
|
||||||
|
|||||||
@@ -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={getConfig().CERTIFICATES_SUPPORT_URL}
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
aria-label="Are you sure?"
|
aria-label="Are you sure?"
|
||||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
|
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -131,30 +131,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>
|
||||||
@@ -242,7 +269,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
aria-label="Are you sure?"
|
aria-label="Are you sure?"
|
||||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
|
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -299,30 +326,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>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
|||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="pgn__hyperlink default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -72,6 +73,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="pgn__hyperlink default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -110,7 +112,15 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Before proceeding, please activate your account.
|
Before proceeding, please
|
||||||
|
<a
|
||||||
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||||
|
target="_self"
|
||||||
|
>
|
||||||
|
activate your account
|
||||||
|
</a>
|
||||||
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,6 +153,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
|||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="pgn__hyperlink default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -181,7 +192,15 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Before proceeding, please unlink all social media accounts.
|
Before proceeding, please
|
||||||
|
<a
|
||||||
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
|
||||||
|
target="_self"
|
||||||
|
>
|
||||||
|
unlink all social media accounts
|
||||||
|
</a>
|
||||||
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import mockData from './mockData';
|
|||||||
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 +25,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,9 +76,52 @@ 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'],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,4 +164,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('Add Twitter profile')).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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -130,8 +130,8 @@ const SummaryPanel = (props) => {
|
|||||||
`}
|
`}
|
||||||
values={{
|
values={{
|
||||||
support_link: (
|
support_link: (
|
||||||
<Alert.Link href={getConfig().LEARNER_SUPPORT_URL}>
|
<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';
|
||||||
@@ -65,18 +65,14 @@ 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 || '[]'),
|
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,
|
||||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT: process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT,
|
|
||||||
ACCOUNT_BASICS_SUPPORT_URL: process.env.ACCOUNT_BASICS_SUPPORT_URL,
|
|
||||||
EMAIL_CONFIRMATION_SUPPORT_URL: process.env.EMAIL_CONFIRMATION_SUPPORT_URL,
|
|
||||||
CERTIFICATES_SUPPORT_URL: process.env.CERTIFICATES_SUPPORT_URL,
|
|
||||||
LEARNER_SUPPORT_URL: process.env.LEARNER_SUPPORT_URL,
|
|
||||||
LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL,
|
LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL,
|
||||||
}, 'App loadConfig override handler');
|
}, 'App loadConfig override handler');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ $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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +136,7 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important;
|
padding-bottom: 10px !important;
|
||||||
border: 1px solid $light-500 !important;
|
border: 1px solid var(--pgn-color-light-500) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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"
|
||||||
@@ -54,6 +55,7 @@ const EmailCadences = ({
|
|||||||
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();
|
||||||
@@ -73,6 +75,7 @@ EmailCadences.propTypes = {
|
|||||||
onToggle: PropTypes.func.isRequired,
|
onToggle: PropTypes.func.isRequired,
|
||||||
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE_PREFERENCES)).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,73 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Dropdown } from '@openedx/paragon';
|
|
||||||
|
|
||||||
import { IDLE_STATUS, SUCCESS_STATUS } from '../constants';
|
|
||||||
import { selectCourseList, selectCourseListStatus, selectSelectedCourseId } from './data/selectors';
|
|
||||||
import { fetchCourseList, setSelectedCourse } from './data/thunks';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
const NotificationCoursesDropdown = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const coursesList = useSelector(selectCourseList());
|
|
||||||
const courseListStatus = useSelector(selectCourseListStatus());
|
|
||||||
const selectedCourseId = useSelector(selectSelectedCourseId());
|
|
||||||
const selectedCourse = useMemo(
|
|
||||||
() => coursesList.find((course) => course.id === selectedCourseId),
|
|
||||||
[coursesList, selectedCourseId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCourseSelection = useCallback((courseId) => {
|
|
||||||
dispatch(setSelectedCourse(courseId));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const fetchCourses = useCallback((page = 1, pageSize = 99999) => {
|
|
||||||
dispatch(fetchCourseList(page, pageSize));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (courseListStatus === IDLE_STATUS) {
|
|
||||||
fetchCourses();
|
|
||||||
}
|
|
||||||
}, [courseListStatus, fetchCourses]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
courseListStatus === SUCCESS_STATUS && (
|
|
||||||
<div className="mb-5">
|
|
||||||
<h5 className="text-primary-500 mb-3">{intl.formatMessage(messages.notificationDropdownlabel)}</h5>
|
|
||||||
<Dropdown className="course-dropdown">
|
|
||||||
<Dropdown.Toggle
|
|
||||||
variant="outline-primary"
|
|
||||||
id="course-dropdown-btn"
|
|
||||||
className="w-100 justify-content-between small"
|
|
||||||
>
|
|
||||||
{selectedCourse?.name}
|
|
||||||
</Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu className="w-100">
|
|
||||||
{coursesList.map((course) => (
|
|
||||||
<Dropdown.Item
|
|
||||||
className="w-100"
|
|
||||||
key={course.id}
|
|
||||||
active={course.id === selectedCourse?.id}
|
|
||||||
eventKey={course.id}
|
|
||||||
onSelect={handleCourseSelection}
|
|
||||||
>
|
|
||||||
{course.name}
|
|
||||||
</Dropdown.Item>
|
|
||||||
))}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
<span className="x-small text-gray-500">
|
|
||||||
{selectedCourse?.name === 'Account'
|
|
||||||
? intl.formatMessage(messages.notificationDropdownApplies)
|
|
||||||
: intl.formatMessage(messages.notificationCourseDropdownApplies)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotificationCoursesDropdown;
|
|
||||||
@@ -13,7 +13,10 @@ 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 {
|
import {
|
||||||
EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES, MIXED,
|
EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES, MIXED,
|
||||||
@@ -22,19 +25,19 @@ import {
|
|||||||
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) => {
|
const getValue = useCallback((notificationChannel, innerText, checked) => {
|
||||||
if (notificationChannel === EMAIL_CADENCE && courseId) {
|
if (notificationChannel === EMAIL_CADENCE) {
|
||||||
return innerText;
|
return innerText;
|
||||||
}
|
}
|
||||||
return checked;
|
return checked;
|
||||||
}, [courseId]);
|
}, []);
|
||||||
|
|
||||||
const getEmailCadence = useCallback((notificationChannel, checked, innerText, emailCadence) => {
|
const getEmailCadence = useCallback((notificationChannel, checked, innerText, emailCadence) => {
|
||||||
if (notificationChannel === EMAIL_CADENCE) {
|
if (notificationChannel === EMAIL_CADENCE) {
|
||||||
@@ -59,14 +62,13 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
dispatch(updatePreferenceToggle(
|
dispatch(updatePreferenceToggle(
|
||||||
courseId,
|
|
||||||
appId,
|
appId,
|
||||||
notificationType,
|
notificationType,
|
||||||
notificationChannel,
|
notificationChannel,
|
||||||
value,
|
value,
|
||||||
emailCadence !== MIXED ? emailCadence : undefined,
|
emailCadence !== MIXED ? emailCadence : undefined,
|
||||||
));
|
));
|
||||||
}, [appPreferences, getValue, getEmailCadence, dispatch, courseId, appId]);
|
}, [appPreferences, getValue, getEmailCadence, dispatch, appId]);
|
||||||
|
|
||||||
const renderPreference = (preference) => (
|
const renderPreference = (preference) => (
|
||||||
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
|
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
|
||||||
@@ -84,8 +86,8 @@ 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 && (
|
||||||
@@ -94,6 +96,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
emailCadence={preference.emailCadence}
|
emailCadence={preference.emailCadence}
|
||||||
notificationType={preference.id}
|
notificationType={preference.id}
|
||||||
|
disabled={nonEditable[preference.id]?.includes(channel)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,23 +9,21 @@ import { Spinner, NavItem } from '@openedx/paragon';
|
|||||||
import { useIsOnMobile } from '../hooks';
|
import { useIsOnMobile } from '../hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import NotificationPreferenceApp from './NotificationPreferenceApp';
|
import NotificationPreferenceApp from './NotificationPreferenceApp';
|
||||||
import { fetchCourseNotificationPreferences } from './data/thunks';
|
import { fetchNotificationPreferences } from './data/thunks';
|
||||||
import { LOADING_STATUS } from '../constants';
|
import { LOADING_STATUS } from '../constants';
|
||||||
import {
|
import {
|
||||||
selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId, selectSelectedCourseId,
|
selectNotificationPreferencesStatus, selectPreferenceAppsId,
|
||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
import { notificationChannels } from './data/utils';
|
import { notificationChannels } from './data/utils';
|
||||||
|
|
||||||
const NotificationPreferences = () => {
|
const NotificationPreferences = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const courseStatus = useSelector(selectCourseListStatus());
|
|
||||||
const courseId = useSelector(selectSelectedCourseId());
|
|
||||||
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 => (
|
||||||
@@ -34,8 +32,8 @@ const NotificationPreferences = () => {
|
|||||||
), [preferenceAppsIds]);
|
), [preferenceAppsIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCourseNotificationPreferences(courseId));
|
dispatch(fetchNotificationPreferences());
|
||||||
}, [courseId, dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
if (preferenceAppsIds.length === 0) {
|
if (preferenceAppsIds.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
@@ -10,6 +11,10 @@ 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 { 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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,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(() => ({
|
||||||
@@ -139,23 +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('update account preference on click', async () => {
|
it('test non editable', async () => {
|
||||||
|
setConfig({
|
||||||
|
SHOW_EMAIL_CHANNEL: 'true',
|
||||||
|
});
|
||||||
store = setupStore({
|
store = setupStore({
|
||||||
...defaultPreferences,
|
...defaultPreferences,
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
selectedCourse: '',
|
selectedCourse: '',
|
||||||
});
|
});
|
||||||
await render(notificationPreferences(store));
|
await render(notificationPreferences(store));
|
||||||
const element = screen.getByTestId('core-web');
|
expect(screen.getByTestId('toggle-core-web')).toBeDisabled();
|
||||||
await fireEvent.click(element);
|
expect(screen.getByTestId('toggle-core-email')).toBeDisabled();
|
||||||
expect(mockDispatch).toHaveBeenCalled();
|
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/v2/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/v2/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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useSelector } from 'react-redux';
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Container, Hyperlink } from '@openedx/paragon';
|
import { Container, Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
import { selectSelectedCourseId, selectShowPreferences } from './data/selectors';
|
import { selectShowPreferences } from './data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import NotificationCoursesDropdown from './NotificationCoursesDropdown';
|
|
||||||
import NotificationPreferences from './NotificationPreferences';
|
import NotificationPreferences from './NotificationPreferences';
|
||||||
import { useFeedbackWrapper } from '../hooks';
|
import { useFeedbackWrapper } from '../hooks';
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ const NotificationSettings = () => {
|
|||||||
useFeedbackWrapper();
|
useFeedbackWrapper();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const showPreferences = useSelector(selectShowPreferences());
|
const showPreferences = useSelector(selectShowPreferences());
|
||||||
const courseId = useSelector(selectSelectedCourseId());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
showPreferences && (
|
showPreferences && (
|
||||||
@@ -22,13 +20,9 @@ const NotificationSettings = () => {
|
|||||||
<h2 className="notification-heading mb-3">
|
<h2 className="notification-heading mb-3">
|
||||||
{intl.formatMessage(messages.notificationHeading)}
|
{intl.formatMessage(messages.notificationHeading)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="text-gray-700 font-size-14 mb-3">
|
|
||||||
{intl.formatMessage(messages.accountNotificationDescription)}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-700 font-size-14 mb-3">
|
<div className="text-gray-700 font-size-14 mb-3">
|
||||||
{intl.formatMessage(messages.notificationCadenceDescription, {
|
{intl.formatMessage(messages.notificationCadenceDescription, {
|
||||||
dailyTime: '22:00 UTC',
|
dailyTime: '22:00 UTC', weeklyTime: '22:00 UTC',
|
||||||
weeklyTime: '22:00 UTC Every Sunday',
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 text-gray-700 font-size-14">
|
<div className="mb-5 text-gray-700 font-size-14">
|
||||||
@@ -42,8 +36,7 @@ const NotificationSettings = () => {
|
|||||||
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
|
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</div>
|
</div>
|
||||||
<NotificationCoursesDropdown />
|
<NotificationPreferences />
|
||||||
<NotificationPreferences courseId={courseId} />
|
|
||||||
<div className="border border-light-700 my-6" />
|
<div className="border border-light-700 my-6" />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,19 +2,15 @@ export const Actions = {
|
|||||||
FETCHED_PREFERENCES: 'fetchedPreferences',
|
FETCHED_PREFERENCES: 'fetchedPreferences',
|
||||||
FETCHING_PREFERENCES: 'fetchingPreferences',
|
FETCHING_PREFERENCES: 'fetchingPreferences',
|
||||||
FAILED_PREFERENCES: 'failedPreferences',
|
FAILED_PREFERENCES: 'failedPreferences',
|
||||||
FETCHING_COURSE_LIST: 'fetchingCourseList',
|
|
||||||
FETCHED_COURSE_LIST: 'fetchedCourseList',
|
|
||||||
FAILED_COURSE_LIST: 'failedCourseList',
|
|
||||||
UPDATE_SELECTED_COURSE: 'updateSelectedCourse',
|
|
||||||
UPDATE_PREFERENCE: 'updatePreference',
|
UPDATE_PREFERENCE: 'updatePreference',
|
||||||
UPDATE_APP_PREFERENCE: 'updateAppValue',
|
UPDATE_APP_PREFERENCE: 'updateAppValue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => (
|
export const fetchNotificationPreferenceSuccess = (payload, showPreferences, isPreferenceUpdate) => dispatch => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference,
|
type: Actions.FETCHED_PREFERENCES, payload, showPreferences, isPreferenceUpdate,
|
||||||
})
|
});
|
||||||
);
|
};
|
||||||
|
|
||||||
export const fetchNotificationPreferenceFetching = () => dispatch => (
|
export const fetchNotificationPreferenceFetching = () => dispatch => (
|
||||||
dispatch({ type: Actions.FETCHING_PREFERENCES })
|
dispatch({ type: Actions.FETCHING_PREFERENCES })
|
||||||
@@ -24,22 +20,6 @@ export const fetchNotificationPreferenceFailed = () => dispatch => (
|
|||||||
dispatch({ type: Actions.FAILED_PREFERENCES })
|
dispatch({ type: Actions.FAILED_PREFERENCES })
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchCourseListSuccess = payload => dispatch => (
|
|
||||||
dispatch({ type: Actions.FETCHED_COURSE_LIST, payload })
|
|
||||||
);
|
|
||||||
|
|
||||||
export const fetchCourseListFetching = () => dispatch => (
|
|
||||||
dispatch({ type: Actions.FETCHING_COURSE_LIST })
|
|
||||||
);
|
|
||||||
|
|
||||||
export const fetchCourseListFailed = () => dispatch => (
|
|
||||||
dispatch({ type: Actions.FAILED_COURSE_LIST })
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateSelectedCourse = courseId => dispatch => (
|
|
||||||
dispatch({ type: Actions.UPDATE_SELECTED_COURSE, courseId })
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updatePreferenceValue = (appId, preferenceName, notificationChannel, value) => dispatch => (
|
export const updatePreferenceValue = (appId, preferenceName, notificationChannel, value) => dispatch => (
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Actions.UPDATE_PREFERENCE,
|
type: Actions.UPDATE_PREFERENCE,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const EMAIL_CADENCE_PREFERENCES = {
|
export const EMAIL_CADENCE_PREFERENCES = {
|
||||||
DAILY: 'Daily',
|
DAILY: 'Daily',
|
||||||
WEEKLY: 'Weekly',
|
WEEKLY: 'Weekly',
|
||||||
|
IMMEDIATELY: 'Immediately',
|
||||||
};
|
};
|
||||||
export const EMAIL_CADENCE = 'email_cadence';
|
export const EMAIL_CADENCE = 'email_cadence';
|
||||||
export const EMAIL = 'email';
|
export const EMAIL = 'email';
|
||||||
|
|||||||
@@ -9,15 +9,9 @@ import { normalizeAccountPreferences } from './thunks';
|
|||||||
|
|
||||||
export const defaultState = {
|
export const defaultState = {
|
||||||
showPreferences: false,
|
showPreferences: false,
|
||||||
courses: {
|
|
||||||
status: IDLE_STATUS,
|
|
||||||
courses: [{ id: '', name: 'Account' }],
|
|
||||||
pagination: {},
|
|
||||||
},
|
|
||||||
preferences: {
|
preferences: {
|
||||||
status: IDLE_STATUS,
|
status: IDLE_STATUS,
|
||||||
updatePreferenceStatus: IDLE_STATUS,
|
updatePreferenceStatus: IDLE_STATUS,
|
||||||
selectedCourse: '',
|
|
||||||
preferences: [],
|
preferences: [],
|
||||||
apps: [],
|
apps: [],
|
||||||
nonEditable: {},
|
nonEditable: {},
|
||||||
@@ -26,35 +20,9 @@ export const defaultState = {
|
|||||||
|
|
||||||
const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
||||||
const {
|
const {
|
||||||
courseId, appId, notificationChannel, preferenceName, value,
|
appId, notificationChannel, preferenceName, value,
|
||||||
} = action;
|
} = action;
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case Actions.FETCHING_COURSE_LIST:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
courses: {
|
|
||||||
...state.courses,
|
|
||||||
status: LOADING_STATUS,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case Actions.FETCHED_COURSE_LIST:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
courses: {
|
|
||||||
status: SUCCESS_STATUS,
|
|
||||||
courses: [...state.courses.courses, ...action.payload.courseList],
|
|
||||||
pagination: action.payload.pagination,
|
|
||||||
},
|
|
||||||
showPreferences: action.payload.showPreferences,
|
|
||||||
};
|
|
||||||
case Actions.FAILED_COURSE_LIST:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
courses: {
|
|
||||||
...state.courses,
|
|
||||||
status: FAILURE_STATUS,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case Actions.FETCHING_PREFERENCES:
|
case Actions.FETCHING_PREFERENCES:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -69,7 +37,7 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
|||||||
case Actions.FETCHED_PREFERENCES:
|
case Actions.FETCHED_PREFERENCES:
|
||||||
{
|
{
|
||||||
const { preferences } = state;
|
const { preferences } = state;
|
||||||
if (action.isAccountPreference) {
|
if (action.isPreferenceUpdate) {
|
||||||
normalizeAccountPreferences(preferences, action.payload);
|
normalizeAccountPreferences(preferences, action.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +49,7 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
|||||||
updatePreferenceStatus: SUCCESS_STATUS,
|
updatePreferenceStatus: SUCCESS_STATUS,
|
||||||
...action.payload,
|
...action.payload,
|
||||||
},
|
},
|
||||||
|
showPreferences: action.showPreferences,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Actions.FAILED_PREFERENCES:
|
case Actions.FAILED_PREFERENCES:
|
||||||
@@ -95,14 +64,6 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
|||||||
nonEditable: {},
|
nonEditable: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case Actions.UPDATE_SELECTED_COURSE:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
preferences: {
|
|
||||||
...state.preferences,
|
|
||||||
selectedCourse: courseId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case Actions.UPDATE_PREFERENCE:
|
case Actions.UPDATE_PREFERENCE:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
|
|
||||||
describe('notification-preferences reducer', () => {
|
describe('notification-preferences reducer', () => {
|
||||||
let state = null;
|
let state = null;
|
||||||
const selectedCourseId = 'selected-course-id';
|
|
||||||
|
|
||||||
const preferenceData = {
|
const preferenceData = {
|
||||||
apps: [{ id: 'discussion', enabled: true }],
|
apps: [{ id: 'discussion', enabled: true }],
|
||||||
@@ -28,53 +27,6 @@ describe('notification-preferences reducer', () => {
|
|||||||
state = reducer();
|
state = reducer();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates course list when api call is successful', () => {
|
|
||||||
const data = {
|
|
||||||
pagination: {
|
|
||||||
count: 1,
|
|
||||||
currentPage: 1,
|
|
||||||
hasMore: false,
|
|
||||||
totalPages: 1,
|
|
||||||
},
|
|
||||||
courseList: [],
|
|
||||||
};
|
|
||||||
const result = reducer(
|
|
||||||
state,
|
|
||||||
{ type: Actions.FETCHED_COURSE_LIST, payload: data },
|
|
||||||
);
|
|
||||||
expect(result.courses).toEqual({
|
|
||||||
status: SUCCESS_STATUS,
|
|
||||||
courses: [{ id: '', name: 'Account' }],
|
|
||||||
pagination: data.pagination,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ action: Actions.FETCHING_COURSE_LIST, status: LOADING_STATUS },
|
|
||||||
{ action: Actions.FAILED_COURSE_LIST, status: FAILURE_STATUS },
|
|
||||||
])('course list is empty when api call is %s', ({ action, status }) => {
|
|
||||||
const result = reducer(
|
|
||||||
state,
|
|
||||||
{ type: action },
|
|
||||||
);
|
|
||||||
expect(result.courses).toEqual({
|
|
||||||
status,
|
|
||||||
courses: [{
|
|
||||||
id: '',
|
|
||||||
name: 'Account',
|
|
||||||
}],
|
|
||||||
pagination: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates selected course id', () => {
|
|
||||||
const result = reducer(
|
|
||||||
state,
|
|
||||||
{ type: Actions.UPDATE_SELECTED_COURSE, courseId: selectedCourseId },
|
|
||||||
);
|
|
||||||
expect(result.preferences.selectedCourse).toEqual(selectedCourseId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates preferences when api call is successful', () => {
|
it('updates preferences when api call is successful', () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
state,
|
state,
|
||||||
@@ -83,7 +35,6 @@ describe('notification-preferences reducer', () => {
|
|||||||
expect(result.preferences).toEqual({
|
expect(result.preferences).toEqual({
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
updatePreferenceStatus: SUCCESS_STATUS,
|
updatePreferenceStatus: SUCCESS_STATUS,
|
||||||
selectedCourse: '',
|
|
||||||
...preferenceData,
|
...preferenceData,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -98,7 +49,6 @@ describe('notification-preferences reducer', () => {
|
|||||||
);
|
);
|
||||||
expect(result.preferences).toEqual({
|
expect(result.preferences).toEqual({
|
||||||
status,
|
status,
|
||||||
selectedCourse: '',
|
|
||||||
preferences: [],
|
preferences: [],
|
||||||
apps: [],
|
apps: [],
|
||||||
nonEditable: {},
|
nonEditable: {},
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export const selectAppNonEditableChannels = (appId) => state => (
|
||||||
|
state.notificationPreferences.preferences?.nonEditable[appId] || {}
|
||||||
|
);
|
||||||
export const selectNotificationPreferencesStatus = () => state => (
|
export const selectNotificationPreferencesStatus = () => state => (
|
||||||
state.notificationPreferences.preferences.status
|
state.notificationPreferences.preferences.status
|
||||||
);
|
);
|
||||||
@@ -10,20 +13,6 @@ export const selectPreferences = () => state => (
|
|||||||
state.notificationPreferences.preferences?.preferences
|
state.notificationPreferences.preferences?.preferences
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectCourseListStatus = () => state => (
|
|
||||||
state.notificationPreferences.courses.status
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectCourseList = () => state => (
|
|
||||||
state.notificationPreferences.courses.courses
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectCourse = courseId => state => (
|
|
||||||
selectCourseList()(state).find(
|
|
||||||
course => course.id === courseId,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectPreferenceAppsId = () => state => (
|
export const selectPreferenceAppsId = () => state => (
|
||||||
state.notificationPreferences.preferences.apps.map(app => app.id)
|
state.notificationPreferences.preferences.apps.map(app => app.id)
|
||||||
);
|
);
|
||||||
@@ -54,14 +43,6 @@ export const selectPreferenceNonEditableChannels = (appId, name) => state => (
|
|||||||
state?.notificationPreferences.preferences.nonEditable[appId]?.[name] || []
|
state?.notificationPreferences.preferences.nonEditable[appId]?.[name] || []
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectSelectedCourseId = () => state => (
|
|
||||||
state.notificationPreferences.preferences.selectedCourse
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectPagination = () => state => (
|
|
||||||
state.notificationPreferences.courses.pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectShowPreferences = () => state => (
|
export const selectShowPreferences = () => state => (
|
||||||
state.notificationPreferences.showPreferences
|
state.notificationPreferences.showPreferences
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,37 +2,12 @@ import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import snakeCase from 'lodash.snakecase';
|
import snakeCase from 'lodash.snakecase';
|
||||||
|
|
||||||
export const getCourseNotificationPreferences = async (courseId) => {
|
export const getNotificationPreferences = async () => {
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/configurations/${courseId}`;
|
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`;
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCourseList = async (page, pageSize) => {
|
|
||||||
const params = snakeCaseObject({ page, pageSize });
|
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/enrollments/`;
|
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const patchPreferenceToggle = async (
|
|
||||||
courseId,
|
|
||||||
notificationApp,
|
|
||||||
notificationType,
|
|
||||||
notificationChannel,
|
|
||||||
value,
|
|
||||||
) => {
|
|
||||||
const patchData = snakeCaseObject({
|
|
||||||
notificationApp,
|
|
||||||
notificationType: snakeCase(notificationType),
|
|
||||||
notificationChannel,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/configurations/${courseId}`;
|
|
||||||
const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const postPreferenceToggle = async (
|
export const postPreferenceToggle = async (
|
||||||
notificationApp,
|
notificationApp,
|
||||||
notificationType,
|
notificationType,
|
||||||
@@ -47,7 +22,7 @@ export const postPreferenceToggle = async (
|
|||||||
value,
|
value,
|
||||||
emailCadence,
|
emailCadence,
|
||||||
});
|
});
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update-all/`;
|
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`;
|
||||||
const { data } = await getAuthenticatedHttpClient().post(url, patchData);
|
const { data } = await getAuthenticatedHttpClient().put(url, patchData);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
73
src/notification-preferences/data/service.test.js
Normal file
73
src/notification-preferences/data/service.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { getNotificationPreferences, postPreferenceToggle } 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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Notification Preferences Service', () => {
|
||||||
|
let mockHttpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://test.lms' });
|
||||||
|
|
||||||
|
mockHttpClient = {
|
||||||
|
get: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNotificationPreferences', () => {
|
||||||
|
it('fetches preferences and returns data', async () => {
|
||||||
|
const mockData = { results: [{ id: 1 }] };
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: mockData });
|
||||||
|
|
||||||
|
const result = await getNotificationPreferences();
|
||||||
|
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||||
|
'http://test.lms/api/notifications/v2/configurations/',
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postPreferenceToggle', () => {
|
||||||
|
it('sends snake-cased payload and returns data', async () => {
|
||||||
|
const mockData = { success: true };
|
||||||
|
mockHttpClient.put.mockResolvedValue({ data: mockData });
|
||||||
|
|
||||||
|
const result = await postPreferenceToggle(
|
||||||
|
'appName',
|
||||||
|
'someType',
|
||||||
|
'email',
|
||||||
|
true,
|
||||||
|
'daily',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockHttpClient.put).toHaveBeenCalledWith(
|
||||||
|
'http://test.lms/api/notifications/v2/configurations/',
|
||||||
|
expect.objectContaining({
|
||||||
|
notification_app: 'appName',
|
||||||
|
notification_type: 'some_type',
|
||||||
|
notification_channel: 'email',
|
||||||
|
value: true,
|
||||||
|
email_cadence: 'daily',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
fetchNotificationPreferenceSuccess,
|
fetchNotificationPreferenceSuccess,
|
||||||
fetchNotificationPreferenceFailed,
|
fetchNotificationPreferenceFailed,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { patchPreferenceToggle, postPreferenceToggle } from './service';
|
import { postPreferenceToggle } from './service';
|
||||||
import { EMAIL } from './constants';
|
import { EMAIL } from './constants';
|
||||||
|
|
||||||
jest.mock('./service', () => ({
|
jest.mock('./service', () => ({
|
||||||
@@ -60,37 +60,9 @@ describe('updatePreferenceToggle', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update preference for a course-specific notification', async () => {
|
it('should update preference globally', async () => {
|
||||||
patchPreferenceToggle.mockResolvedValue({ data: mockData });
|
|
||||||
await updatePreferenceToggle(
|
|
||||||
courseId,
|
|
||||||
notificationApp,
|
|
||||||
notificationType,
|
|
||||||
notificationChannel,
|
|
||||||
value,
|
|
||||||
emailCadence,
|
|
||||||
)(dispatch);
|
|
||||||
|
|
||||||
expect(dispatch).toHaveBeenCalledWith(updatePreferenceValue(
|
|
||||||
notificationApp,
|
|
||||||
notificationType,
|
|
||||||
notificationChannel,
|
|
||||||
!value,
|
|
||||||
));
|
|
||||||
expect(patchPreferenceToggle).toHaveBeenCalledWith(
|
|
||||||
courseId,
|
|
||||||
notificationApp,
|
|
||||||
notificationType,
|
|
||||||
notificationChannel,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceSuccess(courseId, { data: mockData }, false));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update preference globally when courseId is not provided', async () => {
|
|
||||||
postPreferenceToggle.mockResolvedValue({ data: mockData });
|
postPreferenceToggle.mockResolvedValue({ data: mockData });
|
||||||
await updatePreferenceToggle(
|
await updatePreferenceToggle(
|
||||||
null,
|
|
||||||
notificationApp,
|
notificationApp,
|
||||||
notificationType,
|
notificationType,
|
||||||
notificationChannel,
|
notificationChannel,
|
||||||
@@ -115,23 +87,22 @@ describe('updatePreferenceToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle email preferences separately', async () => {
|
it('should handle email preferences separately', async () => {
|
||||||
patchPreferenceToggle.mockResolvedValue({ data: mockData });
|
postPreferenceToggle.mockResolvedValue({ data: mockData });
|
||||||
await updatePreferenceToggle(courseId, notificationApp, notificationType, EMAIL, value, emailCadence)(dispatch);
|
await updatePreferenceToggle(notificationApp, notificationType, EMAIL, value, emailCadence)(dispatch);
|
||||||
|
|
||||||
expect(patchPreferenceToggle).toHaveBeenCalledWith(
|
expect(postPreferenceToggle).toHaveBeenCalledWith(
|
||||||
courseId,
|
|
||||||
notificationApp,
|
notificationApp,
|
||||||
notificationType,
|
notificationType,
|
||||||
EMAIL,
|
EMAIL,
|
||||||
true,
|
true,
|
||||||
|
emailCadence,
|
||||||
);
|
);
|
||||||
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceSuccess(courseId, { data: mockData }, false));
|
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceSuccess(courseId, { data: mockData }, false));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should dispatch fetchNotificationPreferenceFailed on error', async () => {
|
it('should dispatch fetchNotificationPreferenceFailed on error', async () => {
|
||||||
patchPreferenceToggle.mockRejectedValue(new Error('Network Error'));
|
postPreferenceToggle.mockRejectedValue(new Error('Network Error'));
|
||||||
await updatePreferenceToggle(
|
await updatePreferenceToggle(
|
||||||
courseId,
|
|
||||||
notificationApp,
|
notificationApp,
|
||||||
notificationType,
|
notificationType,
|
||||||
notificationChannel,
|
notificationChannel,
|
||||||
|
|||||||
@@ -2,42 +2,16 @@ import { camelCaseObject } from '@edx/frontend-platform';
|
|||||||
import camelCase from 'lodash.camelcase';
|
import camelCase from 'lodash.camelcase';
|
||||||
import { EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES } from './constants';
|
import { EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES } from './constants';
|
||||||
import {
|
import {
|
||||||
fetchCourseListSuccess,
|
|
||||||
fetchCourseListFetching,
|
|
||||||
fetchCourseListFailed,
|
|
||||||
fetchNotificationPreferenceFailed,
|
fetchNotificationPreferenceFailed,
|
||||||
fetchNotificationPreferenceFetching,
|
fetchNotificationPreferenceFetching,
|
||||||
fetchNotificationPreferenceSuccess,
|
fetchNotificationPreferenceSuccess,
|
||||||
updatePreferenceValue,
|
updatePreferenceValue,
|
||||||
updateSelectedCourse,
|
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import {
|
import {
|
||||||
getCourseList,
|
getNotificationPreferences,
|
||||||
getCourseNotificationPreferences,
|
|
||||||
patchPreferenceToggle,
|
|
||||||
postPreferenceToggle,
|
postPreferenceToggle,
|
||||||
} from './service';
|
} from './service';
|
||||||
|
|
||||||
const normalizeCourses = (responseData) => {
|
|
||||||
const courseList = responseData.results?.map((enrollment) => ({
|
|
||||||
id: enrollment.course.id,
|
|
||||||
name: enrollment.course.displayName,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const pagination = {
|
|
||||||
count: responseData.count,
|
|
||||||
currentPage: responseData.currentPage,
|
|
||||||
hasMore: Boolean(responseData.next),
|
|
||||||
totalPages: responseData.numPages,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
courseList,
|
|
||||||
pagination,
|
|
||||||
showPreferences: responseData.showPreferences,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeAccountPreferences = (originalData, updateInfo) => {
|
export const normalizeAccountPreferences = (originalData, updateInfo) => {
|
||||||
const {
|
const {
|
||||||
app, notificationType, channel, updatedValue,
|
app, notificationType, channel, updatedValue,
|
||||||
@@ -54,13 +28,8 @@ export const normalizeAccountPreferences = (originalData, updateInfo) => {
|
|||||||
return originalData;
|
return originalData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePreferences = (responseData, courseId) => {
|
const normalizePreferences = (responseData) => {
|
||||||
let preferences;
|
const preferences = responseData.data;
|
||||||
if (courseId) {
|
|
||||||
preferences = responseData.notificationPreferenceConfig;
|
|
||||||
} else {
|
|
||||||
preferences = responseData.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appKeys = Object.keys(preferences);
|
const appKeys = Object.keys(preferences);
|
||||||
const apps = appKeys.map((appId) => ({
|
const apps = appKeys.map((appId) => ({
|
||||||
@@ -97,41 +66,20 @@ const normalizePreferences = (responseData, courseId) => {
|
|||||||
return normalizedPreferences;
|
return normalizedPreferences;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchCourseList = (page, pageSize) => (
|
export const fetchNotificationPreferences = () => (
|
||||||
async (dispatch) => {
|
async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch(fetchCourseListFetching());
|
|
||||||
const data = await getCourseList(page, pageSize);
|
|
||||||
const normalizedData = normalizeCourses(camelCaseObject(data));
|
|
||||||
dispatch(fetchCourseListSuccess(normalizedData));
|
|
||||||
} catch (errors) {
|
|
||||||
dispatch(fetchCourseListFailed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const fetchCourseNotificationPreferences = (courseId) => (
|
|
||||||
async (dispatch) => {
|
|
||||||
try {
|
|
||||||
dispatch(updateSelectedCourse(courseId));
|
|
||||||
dispatch(fetchNotificationPreferenceFetching());
|
dispatch(fetchNotificationPreferenceFetching());
|
||||||
const data = await getCourseNotificationPreferences(courseId);
|
const data = camelCaseObject(await getNotificationPreferences());
|
||||||
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
|
const normalizedData = normalizePreferences(data);
|
||||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
dispatch(fetchNotificationPreferenceSuccess(normalizedData, data.showPreferences));
|
||||||
} catch (errors) {
|
} catch (errors) {
|
||||||
dispatch(fetchNotificationPreferenceFailed());
|
dispatch(fetchNotificationPreferenceFailed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const setSelectedCourse = courseId => (
|
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(updateSelectedCourse(courseId));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updatePreferenceToggle = (
|
export const updatePreferenceToggle = (
|
||||||
courseId,
|
|
||||||
notificationApp,
|
notificationApp,
|
||||||
notificationType,
|
notificationType,
|
||||||
notificationChannel,
|
notificationChannel,
|
||||||
@@ -149,49 +97,35 @@ export const updatePreferenceToggle = (
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Function to handle data normalization and dispatching success
|
// Function to handle data normalization and dispatching success
|
||||||
const handleSuccessResponse = (data, isGlobal = false) => {
|
const handleSuccessResponse = (data) => {
|
||||||
const processedData = courseId
|
const processedData = camelCaseObject(data);
|
||||||
? normalizePreferences(camelCaseObject(data), courseId)
|
|
||||||
: camelCaseObject(data);
|
|
||||||
|
|
||||||
dispatch(fetchNotificationPreferenceSuccess(courseId, processedData, isGlobal));
|
dispatch(fetchNotificationPreferenceSuccess(processedData, processedData.showPreferences, true));
|
||||||
return processedData;
|
return processedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to toggle preference based on context (course-specific or global)
|
// Function to toggle preference based on context
|
||||||
const togglePreference = async (channel, toggleValue, cadence) => {
|
const togglePreference = async (channel, toggleValue, cadence) => postPreferenceToggle(
|
||||||
if (courseId) {
|
notificationApp,
|
||||||
return patchPreferenceToggle(
|
notificationType,
|
||||||
courseId,
|
channel,
|
||||||
notificationApp,
|
channel === EMAIL_CADENCE ? undefined : toggleValue,
|
||||||
notificationType,
|
cadence,
|
||||||
channel,
|
);
|
||||||
channel === EMAIL_CADENCE ? cadence : toggleValue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return postPreferenceToggle(
|
|
||||||
notificationApp,
|
|
||||||
notificationType,
|
|
||||||
channel,
|
|
||||||
channel === EMAIL_CADENCE ? undefined : toggleValue,
|
|
||||||
cadence,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute the main preference toggle
|
// Execute the main preference toggle
|
||||||
const data = await togglePreference(notificationChannel, value, emailCadence);
|
const data = await togglePreference(notificationChannel, value, emailCadence);
|
||||||
handleSuccessResponse(data, !courseId);
|
handleSuccessResponse(data);
|
||||||
|
|
||||||
// Handle special case for email notifications
|
// Handle special case for email notifications
|
||||||
if (notificationChannel === EMAIL && value) {
|
if (notificationChannel === EMAIL && value) {
|
||||||
const emailCadenceData = await togglePreference(
|
const emailCadenceData = await togglePreference(
|
||||||
EMAIL_CADENCE,
|
EMAIL_CADENCE,
|
||||||
courseId ? undefined : value,
|
value,
|
||||||
EMAIL_CADENCE_PREFERENCES.DAILY,
|
EMAIL_CADENCE_PREFERENCES.DAILY,
|
||||||
);
|
);
|
||||||
|
|
||||||
handleSuccessResponse(emailCadenceData, !courseId);
|
handleSuccessResponse(emailCadenceData);
|
||||||
}
|
}
|
||||||
} catch (errors) {
|
} catch (errors) {
|
||||||
dispatch(updatePreferenceValue(
|
dispatch(updatePreferenceValue(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
export const notificationChannels = () => ({ WEB: 'web', ...(getConfig().SHOW_EMAIL_CHANNEL === 'true' && { EMAIL: 'email' }) });
|
import { parseEnvBoolean } from '../../utils';
|
||||||
|
|
||||||
|
export const notificationChannels = () => ({
|
||||||
|
WEB: 'web',
|
||||||
|
...(parseEnvBoolean(getConfig().SHOW_PUSH_CHANNEL) && { PUSH: 'push' }),
|
||||||
|
...(parseEnvBoolean(getConfig().SHOW_EMAIL_CHANNEL) && { EMAIL: 'email' }),
|
||||||
|
});
|
||||||
|
|
||||||
export const shouldHideAppPreferences = (preferences, appId) => {
|
export const shouldHideAppPreferences = (preferences, appId) => {
|
||||||
const appPreferences = preferences.filter(pref => pref.appId === appId);
|
const appPreferences = preferences.filter(pref => pref.appId === appId);
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ const messages = defineMessages({
|
|||||||
newQuestionPost {New question posts}
|
newQuestionPost {New question posts}
|
||||||
contentReported {Reported content}
|
contentReported {Reported content}
|
||||||
courseUpdates {Course updates}
|
courseUpdates {Course updates}
|
||||||
oraStaffNotifications {ORA new submissions}
|
oraStaffNotifications {New ORA submission for staff grading}
|
||||||
oraGradeAssigned {Essay assignment grade received}
|
oraGradeAssigned {Essay assignment grade received}
|
||||||
|
newInstructorAllLearnersPost {New posts from instructors}
|
||||||
other {{text}}
|
other {{text}}
|
||||||
}`,
|
}`,
|
||||||
description: 'Display text for Notification Types',
|
description: 'Display text for Notification Types',
|
||||||
@@ -90,14 +91,9 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Notifications for certain activities are enabled by default,',
|
defaultMessage: 'Notifications for certain activities are enabled by default,',
|
||||||
description: 'Body of the notification preferences for learner guide',
|
description: 'Body of the notification preferences for learner guide',
|
||||||
},
|
},
|
||||||
accountNotificationDescription: {
|
|
||||||
id: 'account.notification.description',
|
|
||||||
defaultMessage: 'Account-level settings apply to all courses. Notifications for individual courses can be changed within each course and will override account-level settings.',
|
|
||||||
description: 'Account notification description',
|
|
||||||
},
|
|
||||||
notificationCadenceDescription: {
|
notificationCadenceDescription: {
|
||||||
id: 'notification.cadence.description',
|
id: 'notification.cadence.description',
|
||||||
defaultMessage: 'Daily notifications are delivered at {dailyTime}. Weekly notifications are delivered at {weeklyTime}.',
|
defaultMessage: 'Daily email notifications are sent at {dailyTime}. Weekly email notifications are sent every Sunday at {weeklyTime}.',
|
||||||
description: 'Notification cadence description',
|
description: 'Notification cadence description',
|
||||||
},
|
},
|
||||||
notificationDefaultInfo: {
|
notificationDefaultInfo: {
|
||||||
|
|||||||
87
src/plugin-slots/AdditionalProfileFieldsSlot/README.md
Normal file
87
src/plugin-slots/AdditionalProfileFieldsSlot/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Additional Profile Fields
|
||||||
|
|
||||||
|
### Slot ID: `org.openedx.frontend.account.additional_profile_fields.v1`
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This slot is used to replace/modify/hide the additional profile fields in the account page.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
The following `env.config.jsx` will extend the default fields with a additional custom fields through a simple example component.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Using the Example Component
|
||||||
|
Create a file named `env.config.jsx` at the MFE root with this:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||||
|
import Example from './src/plugin-slots/AdditionalProfileFieldsSlot/example';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
pluginSlots: {
|
||||||
|
'org.openedx.frontend.account.additional_profile_fields.v1': {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
op: PLUGIN_OPERATIONS.Insert,
|
||||||
|
widget: {
|
||||||
|
id: 'additional_account_fields',
|
||||||
|
type: DIRECT_PLUGIN,
|
||||||
|
RenderWidget: Example,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Props
|
||||||
|
|
||||||
|
When implementing a plugin for this slot, the following props are available:
|
||||||
|
|
||||||
|
### `updateUserProfile`
|
||||||
|
- **Type**: Function
|
||||||
|
- **Description**: A function for updating the user's profile with new field values. This handles the API call to persist changes to the backend.
|
||||||
|
- **Usage**: Pass an object containing the field updates to be saved to the user's profile preserving the required structure. The function automatically handles the persistence and UI updates.
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
``` javascript
|
||||||
|
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### `profileFieldValues`
|
||||||
|
- **Type**: Array of Objects
|
||||||
|
- **Description**: Contains the current values of all additional profile fields as an array of objects. Each object has a `fieldName` property (string) and a `fieldValue` property (which can be string, boolean, number, or other data types depending on the field type).
|
||||||
|
- **Usage**: Access specific field values by finding the object with the matching `fieldName` and reading its `fieldValue` property. Use array methods like `find()` to locate specific fields.
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"fieldName": "favorite_color",
|
||||||
|
"fieldValue": "red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "data_authorization",
|
||||||
|
"fieldValue": true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `profileFieldErrors`
|
||||||
|
- **Type**: Object
|
||||||
|
- **Description**: Contains validation errors for profile fields. Each key corresponds to a field name, and the value is the error message.
|
||||||
|
- **Usage**: Check for field-specific errors to display validation feedback to users.
|
||||||
|
|
||||||
|
### `formComponents`
|
||||||
|
- **Type**: Object
|
||||||
|
- **Description**: Provides access to reusable form components that are consistent with the rest of the account page styling and behavior. These components follow the platform's design system and include proper validation and accessibility features.
|
||||||
|
- **Usage**: Use these components in your custom fields implementation to maintain UI consistency. Available components include `SwitchContent` for managing different UI states.
|
||||||
|
|
||||||
|
### `refreshUserProfile`
|
||||||
|
- **Type**: Function
|
||||||
|
- **Description**: A function that triggers a refresh of the user's profile data. This can be used after updating profile fields to ensure the UI reflects the latest data from the server.
|
||||||
|
- **Usage**: Call this function when you need to manually reload the user profile information. Note that `updateUserProfile` typically handles data refresh automatically.
|
||||||
104
src/plugin-slots/AdditionalProfileFieldsSlot/example/index.jsx
Normal file
104
src/plugin-slots/AdditionalProfileFieldsSlot/example/index.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Form, Button } from '@openedx/paragon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Straightforward example of how you could use the pluginProps provided by
|
||||||
|
* the AdditionalProfileFieldsSlot to create a custom profile field.
|
||||||
|
*
|
||||||
|
* Here you can set a 'favorite_color' field with radio buttons and
|
||||||
|
* save it to the user's profile, especifically to their `meta` in
|
||||||
|
* the user's model. For more information, see the documentation:
|
||||||
|
*
|
||||||
|
* https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/README.rst#persisting-optional-user-metadata
|
||||||
|
*/
|
||||||
|
const Example = ({
|
||||||
|
updateUserProfile, profileFieldValues, profileFieldErrors, formComponents: { SwitchContent } = {},
|
||||||
|
}) => {
|
||||||
|
const [formMode, setFormMode] = useState('default');
|
||||||
|
|
||||||
|
// Get current favorite color from profileFieldValues
|
||||||
|
const currentColorField = profileFieldValues?.find(field => field.fieldName === 'favorite_color');
|
||||||
|
const currentColor = currentColorField ? currentColorField.fieldValue : '';
|
||||||
|
|
||||||
|
const [value, setValue] = useState(currentColor);
|
||||||
|
const handleChange = e => setValue(e.target.value);
|
||||||
|
|
||||||
|
// Get any validation errors for the favorite_color field
|
||||||
|
const colorFieldError = profileFieldErrors?.favorite_color;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
try {
|
||||||
|
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||||
|
setFormMode('default');
|
||||||
|
} catch (error) {
|
||||||
|
setFormMode('edit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border .border-accent-500 p-3">
|
||||||
|
<h3 className="h3">Example Additional Profile Fields Slot</h3>
|
||||||
|
|
||||||
|
<SwitchContent
|
||||||
|
expression={formMode}
|
||||||
|
cases={{
|
||||||
|
default: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-muted">
|
||||||
|
{value ? `Selected value: ${value}` : 'No color selected'}
|
||||||
|
</h3>
|
||||||
|
<Button onClick={() => setFormMode('edit')}>Edit</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
edit: (
|
||||||
|
<>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Which Color?</Form.Label>
|
||||||
|
<Form.RadioSet
|
||||||
|
name="colors"
|
||||||
|
onChange={handleChange}
|
||||||
|
value={value}
|
||||||
|
isInvalid={!!colorFieldError}
|
||||||
|
>
|
||||||
|
<Form.Radio value="red">Red</Form.Radio>
|
||||||
|
<Form.Radio value="green">Green</Form.Radio>
|
||||||
|
<Form.Radio value="blue">Blue</Form.Radio>
|
||||||
|
</Form.RadioSet>
|
||||||
|
{colorFieldError && (
|
||||||
|
<Form.Control.Feedback type="invalid">
|
||||||
|
{colorFieldError}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
)}
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} disabled={!value}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Example.propTypes = {
|
||||||
|
updateUserProfile: PropTypes.func.isRequired,
|
||||||
|
profileFieldValues: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
fieldName: PropTypes.string.isRequired,
|
||||||
|
fieldValue: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.number,
|
||||||
|
]).isRequired,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
profileFieldErrors: PropTypes.objectOf(PropTypes.string),
|
||||||
|
formComponents: PropTypes.shape({
|
||||||
|
SwitchContent: PropTypes.elementType.isRequired,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Example;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user