Compare commits

..

1 Commits

Author SHA1 Message Date
Brayan Cerón
db7c7babd7 feat: add slot to extend the profile fields [Backport #1254] (#1375)
* feat: add ExtendedProfileFieldsSlot component

* feat: update ExtendedProfileFieldsSlot documentation and images

* fix: format code in ExtendedProfileFieldsSlot README for consistency

* feat: add error handling to ExtendedProfileFieldsSlot component

* feat: replace ExtendedProfileFieldsSlot with AdditionalProfileFieldsSlot and update documentation

* feat: add Example component for AdditionalProfileFieldsSlot and update README

* fix: update README to reflect correct slot name and widget ID for AdditionalProfileFieldsSlot

* chore: remove unused default_fields.png image
2025-12-04 15:51:47 -05:00
97 changed files with 3320 additions and 11514 deletions

3
.env
View File

@@ -12,7 +12,6 @@ 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=''
@@ -35,5 +34,3 @@ PASSWORD_RESET_SUPPORT_LINK=''
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]' COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -28,7 +28,6 @@ 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=
@@ -36,5 +35,3 @@ PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]' COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -24,7 +24,6 @@ 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=''
@@ -33,4 +32,3 @@ 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={}

2
.nvmrc
View File

@@ -1 +1 @@
24 20

View File

@@ -41,6 +41,17 @@ 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

View File

@@ -104,12 +104,6 @@ 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
========================= =========================
@@ -218,7 +212,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/openedx/frontend-app-account/ :target: https://codecov.io/gh/edx/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

View File

@@ -1,15 +0,0 @@
coverage:
status:
project:
default:
enabled: yes
target: auto
threshold: 0%
patch:
default:
enabled: yes
target: auto
threshold: 0%
ignore:
- "src/i18n"
- "src/index.jsx"

11623
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@
}, },
"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",
@@ -32,23 +31,23 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.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.4.0", "@edx/frontend-platform": "^8.3.3",
"@edx/openedx-atlas": "^0.7.0", "@edx/openedx-atlas": "^0.6.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.6", "@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.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.12.1", "bowser": "2.11.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"core-js": "3.46.0", "core-js": "3.41.0",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"form-urlencoded": "6.1.6", "form-urlencoded": "6.1.5",
"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",
@@ -85,10 +84,12 @@
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.5.0", "@edx/browserslist-config": "1.5.0",
"@openedx/frontend-build": "^14.6.2", "@edx/reactifex": "2.2.0",
"@testing-library/jest-dom": "6.9.1", "@openedx/frontend-build": "^14.3.3",
"@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"
} }
} }

View File

@@ -14,7 +14,7 @@ import {
getLanguageList, getLanguageList,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { import {
Container, Hyperlink, Icon, Alert, 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,7 +50,7 @@ import {
FIELD_LABELS, FIELD_LABELS,
} from './data/constants'; } from './data/constants';
import { fetchSiteLanguages } from './site-language'; import { fetchSiteLanguages } from './site-language';
import { fetchNotificationPreferences } from '../notification-preferences/data/thunks'; import { fetchCourseList } 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'; import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
@@ -76,7 +76,7 @@ class AccountSettingsPage extends React.Component {
} }
componentDidMount() { componentDidMount() {
this.props.fetchNotificationPreferences(); this.props.fetchCourseList();
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', {
@@ -855,24 +855,24 @@ class AccountSettingsPage extends React.Component {
} = this.props; } = this.props;
return ( return (
<Container className="page__account-settings py-5" size="xl"> <div className="page__account-settings container-fluid py-5">
{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-3"> <div className="col-md-2">
<JumpNav /> <JumpNav />
</div> </div>
<div className="col-md-9"> <div className="col-md-10">
{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>
</Container> </div>
); );
} }
} }
@@ -948,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,
fetchNotificationPreferences: PropTypes.func.isRequired, fetchCourseList: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({ tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool, connected: PropTypes.bool,
})), })),
@@ -1013,7 +1013,7 @@ AccountSettingsPage.defaultProps = {
}; };
export default withLocation(withNavigate(connect(accountSettingsPageSelector, { export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
fetchNotificationPreferences, fetchCourseList,
fetchSettings, fetchSettings,
saveSettings, saveSettings,
saveMultipleSettings, saveMultipleSettings,

View File

@@ -1,9 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 { useCallback, useEffect, useState } from 'react'; import React, { 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" data-testid="error-message"> <Form.Control.Feedback type="invalid" key="general-error">
{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} data-testid="open-modal-button"> <Button variant="primary" onClick={open}>
{intl.formatMessage(messages['account.settings.field.dob.form.button'])} {intl.formatMessage(messages['account.settings.field.dob.form.button'])}
</Button> </Button>
<ModalDialog <ModalDialog
@@ -81,27 +81,25 @@ const DOBModal = (props) => {
onClose={handleClose} onClose={handleClose}
hasCloseButton={false} hasCloseButton={false}
variant="default" variant="default"
data-testid="dob-modal"
> >
<form onSubmit={handleSubmit} data-testid="dob-form"> <form onSubmit={handleSubmit}>
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title"> <ModalDialog.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 data-testid="help-text">{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p> <p>{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
<Form.Group> <Form.Group>
<Form.Label data-testid="month-label"> <Form.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 => (
@@ -110,14 +108,13 @@ const DOBModal = (props) => {
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Form.Label data-testid="year-label"> <Form.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 => (
@@ -130,7 +127,7 @@ const DOBModal = (props) => {
<ModalDialog.Footer> <ModalDialog.Footer>
<ActionRow> <ActionRow>
<ModalDialog.CloseButton variant="tertiary" data-testid="cancel-button"> <ModalDialog.CloseButton variant="tertiary">
Cancel Cancel
</ModalDialog.CloseButton> </ModalDialog.CloseButton>
<StatefulButton <StatefulButton
@@ -140,7 +137,6 @@ 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>
@@ -155,6 +151,7 @@ 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 = {
@@ -162,4 +159,4 @@ DOBModal.defaultProps = {
error: undefined, error: undefined,
}; };
export default connect(editableFieldSelector)(DOBModal); export default connect(editableFieldSelector)(injectIntl(DOBModal));

View File

@@ -1,7 +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 classNames from 'classnames'; import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
Button, Form, StatefulButton, Button, Form, StatefulButton,
} from '@openedx/paragon'; } from '@openedx/paragon';
@@ -38,10 +39,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();
@@ -84,13 +85,9 @@ const EditableField = (props) => {
if (!confirmationMessageDefinition || !confirmationValue) { if (!confirmationMessageDefinition || !confirmationValue) {
return null; return null;
} }
return ( return intl.formatMessage(confirmationMessageDefinition, {
<span data-testid="editable-field-confirmation"> value: confirmationValue,
{intl.formatMessage(confirmationMessageDefinition, { });
value: confirmationValue,
})}
</span>
);
}; };
return ( return (
@@ -99,7 +96,7 @@ const EditableField = (props) => {
cases={{ cases={{
editing: ( editing: (
<> <>
<form onSubmit={handleSubmit} data-testid="editable-field-form"> <form onSubmit={handleSubmit}>
<Form.Group <Form.Group
controlId={id} controlId={id}
isInvalid={error != null} isInvalid={error != null}
@@ -112,11 +109,10 @@ 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} data-testid="editable-field-error">{error}</Form.Control.Feedback>} {error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
{others.children} {others.children}
</Form.Group> </Form.Group>
<p> <p>
@@ -138,21 +134,16 @@ 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) && ( {['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
)}
</> </>
), ),
default: ( default: (
@@ -160,7 +151,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" data-testid="editable-field-edit" data-clicked="edit"> <Button variant="link" onClick={handleEdit} className="ml-3">
<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}
@@ -197,6 +188,7 @@ 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 = {
@@ -217,4 +209,4 @@ EditableField.defaultProps = {
export default connect(editableFieldSelector, { export default connect(editableFieldSelector, {
onEdit: openForm, onEdit: openForm,
onCancel: closeForm, onCancel: closeForm,
})(EditableField); })(injectIntl(EditableField));

View File

@@ -1,6 +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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
Button, Form, StatefulButton, Button, Form, StatefulButton,
} from '@openedx/paragon'; } from '@openedx/paragon';
@@ -18,7 +19,6 @@ 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,6 +39,7 @@ const EditableSelectField = (props) => {
isEditing, isEditing,
isEditable, isEditable,
isGrayedOut, isGrayedOut,
intl,
...others ...others
} = props; } = props;
const id = `field-${name}`; const id = `field-${name}`;
@@ -226,6 +227,7 @@ 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 = {
@@ -247,4 +249,4 @@ EditableSelectField.defaultProps = {
export default connect(editableFieldSelector, { export default connect(editableFieldSelector, {
onEdit: openForm, onEdit: openForm,
onCancel: closeForm, onCancel: closeForm,
})(EditableSelectField); })(injectIntl(EditableSelectField));

View File

@@ -1,8 +1,9 @@
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 { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { import {
Button, StatefulButton, Form, Tooltip, OverlayTrigger, Button, StatefulButton, Form,
} 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';
@@ -34,9 +35,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();
@@ -161,16 +162,7 @@ const EmailField = (props) => {
</Button> </Button>
) : null} ) : null}
</div> </div>
<OverlayTrigger <p data-hj-suppress>{renderValue()}</p>
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>
), ),
@@ -199,6 +191,7 @@ 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 = {
@@ -217,4 +210,4 @@ EmailField.defaultProps = {
export default connect(editableFieldSelector, { export default connect(editableFieldSelector, {
onEdit: openForm, onEdit: openForm,
onCancel: closeForm, onCancel: closeForm,
})(EmailField); })(injectIntl(EmailField));

View File

@@ -1,17 +1,19 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 = ({
const intl = useIntl(); intl,
}) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth; const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
return ( return (
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}> <div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<Scrollspy <Scrollspy
items={[ items={[
'basic-information', 'basic-information',
@@ -69,4 +71,8 @@ const JumpNav = () => {
); );
}; };
export default JumpNav; JumpNav.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(JumpNav);

View File

@@ -29,6 +29,7 @@
} }
} }
.custom-switch { .custom-switch {
padding: 0; padding: 0;
max-width: 500px; max-width: 500px;
@@ -43,8 +44,3 @@
filter: alpha(opacity = 60); /* MSIE */ filter: alpha(opacity = 60); /* MSIE */
} }
} }
#tooltip-email .small {
display: block;
margin: 0 !important;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
closeForm, closeForm,
@@ -22,6 +22,7 @@ import commonMessages from '../AccountSettingsPage.messages';
import messages from './messages'; import messages from './messages';
const CertificatePreference = ({ const CertificatePreference = ({
intl,
fieldName, fieldName,
originalFullName, originalFullName,
originalVerifiedName, originalVerifiedName,
@@ -32,7 +33,6 @@ 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,6 +155,7 @@ 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,
@@ -169,4 +170,4 @@ CertificatePreference.defaultProps = {
useVerifiedNameForCerts: false, useVerifiedNameForCerts: false,
}; };
export default connect(certPreferenceSelector)(CertificatePreference); export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));

View File

@@ -1,76 +0,0 @@
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);
});
});

View File

@@ -10,8 +10,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 } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } 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', () => ({
@@ -30,6 +29,8 @@ 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', () => {
@@ -37,7 +38,7 @@ describe('NameChange', () => {
let store = {}; let store = {};
const formId = 'useVerifiedNameForCerts'; const formId = 'useVerifiedNameForCerts';
const updateDraft = 'UPDATE_DRAFT'; const updateDraft = 'UPDATE_DRAFT';
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage; const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
const reduxWrapper = children => ( const reduxWrapper = children => (
<Router> <Router>
@@ -55,6 +56,7 @@ 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(() => ({
@@ -74,7 +76,7 @@ describe('NameChange', () => {
originalVerifiedName: '', originalVerifiedName: '',
}; };
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />)); const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
@@ -85,7 +87,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true, useVerifiedNameForCerts: true,
}; };
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(false); expect(checkbox.checked).toEqual(false);
@@ -100,7 +102,7 @@ describe('NameChange', () => {
}); });
it('triggers modal when attempting to uncheck checkbox', () => { it('triggers modal when attempting to uncheck checkbox', () => {
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true); expect(checkbox.checked).toEqual(true);
@@ -112,7 +114,7 @@ describe('NameChange', () => {
}); });
it('updates draft when changing radio value', () => { it('updates draft when changing radio value', () => {
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox); fireEvent.click(checkbox);
@@ -130,7 +132,7 @@ describe('NameChange', () => {
}); });
it('clears draft on cancel', () => { it('clears draft on cancel', () => {
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox); fireEvent.click(checkbox);
@@ -143,7 +145,7 @@ describe('NameChange', () => {
}); });
it('submits', () => { it('submits', () => {
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox); fireEvent.click(checkbox);
@@ -163,7 +165,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true, useVerifiedNameForCerts: true,
}; };
render(reduxWrapper(<CertificatePreference {...props} />)); render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText); const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true); expect(checkbox.checked).toEqual(true);

View File

@@ -135,3 +135,7 @@ 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';

View File

@@ -1,181 +0,0 @@
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');
});
});
});

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } 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';
@@ -12,8 +13,7 @@ import messages from './messages';
import Alert from '../Alert'; import Alert from '../Alert';
const BeforeProceedingBanner = (props) => { const BeforeProceedingBanner = (props) => {
const { instructionMessageId, supportArticleUrl } = props; const { instructionMessageId, intl, supportArticleUrl } = props;
const intl = useIntl();
return ( return (
<Alert <Alert
@@ -41,7 +41,8 @@ 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 BeforeProceedingBanner; export default injectIntl(BeforeProceedingBanner);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
jest.mock('react-dom', () => ({ jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'), ...jest.requireActual('react-dom'),
@@ -8,16 +9,19 @@ 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">
<BeforeProceedingBanner <IntlBeforeProceedingBanner
{...props} {...props}
/> />
</IntlProvider> </IntlProvider>
@@ -29,12 +33,13 @@ 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">
<BeforeProceedingBanner <IntlBeforeProceedingBanner
{...props} {...props}
/> />
</IntlProvider> </IntlProvider>

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
AlertModal, AlertModal,
Button, Form, ActionRow, Button, Input, ValidationFormGroup, 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,7 +78,6 @@ 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>
@@ -108,26 +107,22 @@ export class ConfirmationModal extends Component {
<PrintingInstructions /> <PrintingInstructions />
</p> </p>
</Alert> </Alert>
<Form.Group <ValidationFormGroup
for={passwordFieldId} for={passwordFieldId}
isInvalid={errorType !== null} invalid={errorType !== null}
invalidMessage={intl.formatMessage(invalidMessage)}
> >
<Form.Label className="d-block" htmlFor={passwordFieldId}> <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'])}
</Form.Label> </label>
<Form.Control <Input
name="password" name="password"
id={passwordFieldId} id={passwordFieldId}
type="password" type="password"
value={password} value={password}
onChange={onChange} onChange={onChange}
/> />
{errorType !== null && ( </ValidationFormGroup>
<Form.Control.Feedback type="invalid" feedback-for={passwordFieldId}>
{intl.formatMessage(invalidMessage)}
</Form.Control.Feedback>
)}
</Form.Group>
</div> </div>
</AlertModal> </AlertModal>

View File

@@ -1,11 +1,11 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import React from 'react';
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 = () => { const PrintingInstructions = (props) => {
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
@@ -13,7 +13,7 @@ const PrintingInstructions = () => {
// We've removed the link from the default message. // We've removed the link from the default message.
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates" destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
> >
{intl.formatMessage(messages['account.settings.delete.account.text.3.link'])} {props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink> </Hyperlink>
); );
@@ -40,4 +40,8 @@ const PrintingInstructions = () => {
); );
}; };
export default PrintingInstructions; PrintingInstructions.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PrintingInstructions);

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 intl = useIntl(); const { status, intl, onClose } = props;
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">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton> <ModalCloseButton className="float-right" variant="link">Close</ModalCloseButton>
</p> </p>
</div> </div>
@@ -31,6 +31,7 @@ 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,
}; };
@@ -38,4 +39,4 @@ SuccessModal.defaultProps = {
status: null, status: null,
}; };
export default SuccessModal; export default injectIntl(SuccessModal);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } 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';
@@ -9,6 +10,8 @@ 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 = {};
@@ -22,22 +25,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"><SuccessModal {...props} /></IntlProvider>)).toJSON(); <IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
await waitFor(() => { await waitFor(() => {
const tree = renderer.create(( const tree = renderer.create((
<IntlProvider locale="en"><SuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON(); <IntlProvider locale="en"><IntlSuccessModal {...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"><SuccessModal {...props} status="pending" /></IntlProvider>)).toJSON(); <IntlProvider locale="en"><IntlSuccessModal {...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"><SuccessModal {...props} status="failed" /></IntlProvider>)).toJSON(); <IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
}); });
@@ -46,7 +49,7 @@ describe('SuccessModal', () => {
await waitFor(() => { await waitFor(() => {
const tree = renderer.create( const tree = renderer.create(
<IntlProvider locale="en"> <IntlProvider locale="en">
<SuccessModal <IntlSuccessModal
{...props} {...props}
status="deleted" status="deleted"
/> />

View File

@@ -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__modal-visible-overflow pgn__alert-modal" className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog" role="dialog"
> >
<div <div
@@ -131,57 +131,30 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
</div> </div>
</div> </div>
<div <div
className="pgn__form-group" className="form-group"
for="passwordFieldId" data-testid="validation-form-group"
> >
<label <label
className="pgn__form-label d-block" className="d-block"
htmlFor="form-field3" htmlFor="passwordFieldId"
> >
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>
<div <input
className="pgn__form-control-decorator-group" aria-describedby="passwordFieldId-invalid-feedback"
className="form-control is-invalid"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
> >
<input A password is required
aria-describedby="form-field3-5" </strong>
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>
@@ -269,7 +242,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__modal-visible-overflow pgn__alert-modal" className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog" role="dialog"
> >
<div <div
@@ -326,28 +299,30 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
</div> </div>
</div> </div>
<div <div
className="pgn__form-group" className="form-group"
for="passwordFieldId" data-testid="validation-form-group"
> >
<label <label
className="pgn__form-label d-block" className="d-block"
htmlFor="form-field1" htmlFor="passwordFieldId"
> >
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>
<div <input
className="pgn__form-control-decorator-group" aria-describedby=""
className="form-control"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
> >
<input Unable to delete account
className="has-value form-control" </strong>
id="form-field1"
name="password"
onBlur={[Function]}
onChange={[Function]}
type="password"
value="fluffy bunnies"
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,65 +0,0 @@
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);
});
});

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react'; import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
ActionRow, ActionRow,
Alert, Alert,
@@ -25,6 +25,7 @@ const NameChangeModal = ({
targetFormId, targetFormId,
errors, errors,
formValues, formValues,
intl,
saveState, saveState,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -32,7 +33,6 @@ 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,10 +193,11 @@ 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)(NameChangeModal); export default connect(nameChangeSelector)(injectIntl(NameChangeModal));

View File

@@ -1,56 +0,0 @@
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);
});
});

View File

@@ -1,4 +1,5 @@
/* 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';
@@ -9,7 +10,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 } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } 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', () => ({
@@ -28,6 +29,8 @@ 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', () => {
@@ -52,6 +55,7 @@ describe('NameChange', () => {
verified_name: 'edX Verified', verified_name: 'edX Verified',
}, },
saveState: null, saveState: null,
intl: {},
}; };
auth.getAuthenticatedHttpClient = jest.fn(() => ({ auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -68,7 +72,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(<NameChange {...props} />)); render(reduxWrapper(<IntlNameChange {...props} />));
expect(getInput()).toBeNull(); expect(getInput()).toBeNull();
const continueButton = screen.getByText('Continue'); const continueButton = screen.getByText('Continue');
@@ -85,7 +89,7 @@ describe('NameChange', () => {
name: 'edx edx', name: 'edx edx',
}, },
}; };
render(reduxWrapper(<NameChange {...formProps} />)); render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue'); const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton); fireEvent.click(continueButton);
@@ -103,7 +107,7 @@ describe('NameChange', () => {
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE', type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
}; };
render(reduxWrapper(<NameChange {...props} />)); render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue'); const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton); fireEvent.click(continueButton);
@@ -130,7 +134,7 @@ describe('NameChange', () => {
targetFormId: 'name', targetFormId: 'name',
}; };
render(reduxWrapper(<NameChange {...formProps} />)); render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue'); const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton); fireEvent.click(continueButton);
@@ -146,7 +150,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(<NameChange {...props} />)); render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue'); const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton); fireEvent.click(continueButton);
@@ -162,7 +166,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(<NameChange {...props} />)); render(reduxWrapper(<IntlNameChange {...props} />));
expect(window.location.pathname).toEqual('/id-verification'); expect(window.location.pathname).toEqual('/id-verification');
}); });
}); });

View File

@@ -1,6 +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 { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, 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';
@@ -9,9 +10,7 @@ import ConfirmationAlert from './ConfirmationAlert';
import RequestInProgressAlert from './RequestInProgressAlert'; import RequestInProgressAlert from './RequestInProgressAlert';
const ResetPassword = (props) => { const ResetPassword = (props) => {
const { email, status } = props; const { email, intl, status } = props;
const intl = useIntl();
return ( return (
<div className="form-group"> <div className="form-group">
<h6 aria-level="3"> <h6 aria-level="3">
@@ -52,6 +51,7 @@ 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,
}, },
)(ResetPassword); )(injectIntl(ResetPassword));

View File

@@ -1,65 +0,0 @@
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);
});
});

View File

@@ -1,95 +0,0 @@
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();
});
});
});

View File

@@ -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(() => [{ code: 'US', name: 'United States' }]), getCountryList: jest.fn(),
})); }));
jest.mock('react-redux', () => ({ jest.mock('react-redux', () => ({
@@ -25,19 +25,6 @@ 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];
@@ -76,52 +63,9 @@ 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'],
}; };
}); });
@@ -164,70 +108,4 @@ 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();
});
}); });

View File

@@ -1,144 +0,0 @@
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 });
});
});

View File

@@ -1,184 +0,0 @@
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();
});
});

View File

@@ -1,9 +1,10 @@
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 } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import EditableSelectField from '../EditableSelectField'; import EditableSelectField from '../EditableSelectField';
@@ -16,6 +17,8 @@ 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', () => {
@@ -85,7 +88,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(<EditableSelectField {...props} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -95,7 +98,7 @@ describe('EditableSelectField', () => {
isEditing: true, isEditing: true,
}; };
const tree = renderer.create(reduxWrapper(<EditableSelectField {...props} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -104,7 +107,7 @@ describe('EditableSelectField', () => {
...props, ...props,
error: 'This is an error message', error: 'This is an error message',
}; };
const tree = renderer.create(reduxWrapper(<EditableSelectField {...errorProps} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...errorProps} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -123,7 +126,7 @@ describe('EditableSelectField', () => {
}, },
], ],
}; };
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroup} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroup} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -137,7 +140,7 @@ describe('EditableSelectField', () => {
}, },
], ],
}; };
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithoutGroup} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithoutGroup} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -160,7 +163,7 @@ describe('EditableSelectField', () => {
}, },
], ],
}; };
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroups} />)).toJSON(); const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroups} />)).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
}); });

View File

@@ -1,16 +1,20 @@
import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } 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(() => {
@@ -23,6 +27,9 @@ describe('JumpNav', () => {
}, },
}); });
props = {
intl: {},
};
store = configureStore({ store = configureStore({
notificationPreferences: { notificationPreferences: {
showPreferences: false, showPreferences: false,
@@ -38,7 +45,7 @@ describe('JumpNav', () => {
render( render(
<IntlProvider locale="en"> <IntlProvider locale="en">
<AppProvider store={store}> <AppProvider store={store}>
<JumpNav /> <IntlJumpNav {...props} />
</AppProvider> </AppProvider>
</IntlProvider>, </IntlProvider>,
); );
@@ -51,10 +58,14 @@ 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}>
<JumpNav /> <IntlJumpNav {...props} />
</AppProvider> </AppProvider>
</IntlProvider>, </IntlProvider>,
); );

View File

@@ -6,7 +6,7 @@ const mockData = {
data: null, data: null,
values: { values: {
username: 'test_username', username: 'test_username',
country: 'US', country: 'AD',
accomplishments_shared: false, accomplishments_shared: false,
name: 'test_name', name: 'test_name',
email: 'test_email@test.com', email: 'test_email@test.com',
@@ -18,18 +18,8 @@ const mockData = {
field_value: '', field_value: '',
}, },
], ],
gender: 'm', gender: null,
'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: {},
@@ -37,14 +27,14 @@ const mockData = {
saveState: null, saveState: null,
timeZones: [ timeZones: [
{ {
time_zone: 'America/New_York', time_zone: 'Africa/Abidjan',
description: 'America/New_York (EST, UTC-0500)', description: 'Africa/Abidjan (GMT, UTC+0000)',
}, },
], ],
countryTimeZones: [ countryTimeZones: [
{ {
time_zone: 'America/New_York', time_zone: 'Europe/Andorra',
description: 'America/New_York (EST, UTC-0500)', description: 'Europe/Andorra (CET, UTC+0100)',
}, },
], ],
previousSiteLanguage: null, previousSiteLanguage: null,

View File

@@ -1,19 +1,21 @@
import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 = () => { const Head = ({ intl }) => (
const intl = useIntl(); <Helmet>
return ( <title>
<Helmet> {intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })}
<title> </title>
{intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })} <link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</title> </Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" /> );
</Helmet>
); Head.propTypes = {
intl: intlShape.isRequired,
}; };
export default Head; export default injectIntl(Head);

View File

@@ -6,8 +6,9 @@ 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 /></IntlProvider>); render(<IntlProvider locale="en"><Head {...props} /></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');

View File

@@ -1,13 +1,12 @@
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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } 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 }) => { const AccessBlocked = ({ error, intl }) => {
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>;
@@ -43,7 +42,8 @@ const AccessBlocked = ({ error }) => {
}; };
AccessBlocked.propTypes = { AccessBlocked.propTypes = {
intl: intlShape.isRequired,
error: PropTypes.string.isRequired, error: PropTypes.string.isRequired,
}; };
export default AccessBlocked; export default injectIntl(AccessBlocked);

View File

@@ -1,44 +1,41 @@
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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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) => (
const intl = useIntl(); <div>
<Collapsible
return ( styling="card"
<div> title={props.intl.formatMessage(messages['id.verification.camera.help.sight.question'])}
<Collapsible className="mb-4 shadow"
styling="card" defaultOpen={props.isOpen}
title={intl.formatMessage(messages['id.verification.camera.help.sight.question'])} >
className="mb-4 shadow" <p>
defaultOpen={props.isOpen} {props.intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])}
> </p>
<p> </Collapsible>
{intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])} <Collapsible
</p> styling="card"
</Collapsible> title={props.intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])}
<Collapsible className="mb-4 shadow"
styling="card" defaultOpen={props.isOpen}
title={intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])} >
className="mb-4 shadow" <p>
defaultOpen={props.isOpen} {props.intl.formatMessage(
> messages['id.verification.camera.help.difficulty.answer'],
<p> { siteName: getConfig().SITE_NAME },
{intl.formatMessage( )}
messages['id.verification.camera.help.difficulty.answer'], </p>
{ siteName: getConfig().SITE_NAME }, </Collapsible>
)} </div>
</p> );
</Collapsible>
</div>
);
};
CameraHelp.propTypes = { CameraHelp.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
isPortrait: PropTypes.bool, isPortrait: PropTypes.bool,
}; };
@@ -48,4 +45,4 @@ CameraHelp.defaultProps = {
isPortrait: false, isPortrait: false,
}; };
export default CameraHelp; export default injectIntl(CameraHelp);

View File

@@ -1,7 +1,7 @@
import { useState, useContext } from 'react'; import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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,7 +11,6 @@ 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);
@@ -28,23 +27,24 @@ const CameraHelpWithUpload = (props) => {
<div> <div>
<Collapsible <Collapsible
styling="card" styling="card"
title={intl.formatMessage(messages['id.verification.id.photo.unclear.question'])} title={props.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={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />} {idPhotoFile && hasUploadedImage && <ImagePreview src={idPhotoFile} alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
<p> <p>
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])} {props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
<SupportedMediaTypes /> <SupportedMediaTypes />
</p> </p>
<ImageFileUpload onFileChange={setAndTrackIdPhotoFile} /> <ImageFileUpload onFileChange={setAndTrackIdPhotoFile} intl={props.intl} />
</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 CameraHelpWithUpload; export default injectIntl(CameraHelpWithUpload);

View File

@@ -1,13 +1,12 @@
import { useContext } from 'react'; import React, { useContext } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 = () => { const CollapsibleImageHelp = (props) => {
const intl = useIntl();
const { const {
userId, useCameraForId, setUseCameraForId, userId, useCameraForId, setUseCameraForId,
} = useContext(IdVerificationContext); } = useContext(IdVerificationContext);
@@ -26,15 +25,15 @@ const CollapsibleImageHelp = () => {
<Collapsible <Collapsible
styling="card" styling="card"
title={useCameraForId title={useCameraForId
? intl.formatMessage(messages['id.verification.photo.upload.help.title']) ? props.intl.formatMessage(messages['id.verification.photo.upload.help.title'])
: intl.formatMessage(messages['id.verification.photo.camera.help.title'])} : props.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
? intl.formatMessage(messages['id.verification.photo.upload.help.text']) ? props.intl.formatMessage(messages['id.verification.photo.upload.help.text'])
: intl.formatMessage(messages['id.verification.photo.camera.help.text'])} : props.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
@@ -43,11 +42,15 @@ const CollapsibleImageHelp = () => {
style={{ marginTop: '0.5rem' }} style={{ marginTop: '0.5rem' }}
> >
{useCameraForId {useCameraForId
? intl.formatMessage(messages['id.verification.photo.upload.help.button']) ? props.intl.formatMessage(messages['id.verification.photo.upload.help.button'])
: intl.formatMessage(messages['id.verification.photo.camera.help.button'])} : props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
</Button> </Button>
</Collapsible> </Collapsible>
); );
}; };
export default CollapsibleImageHelp; CollapsibleImageHelp.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CollapsibleImageHelp);

View File

@@ -46,11 +46,6 @@ const messages = defineMessages({
defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a drivers license or passport.', defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a drivers 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',
@@ -661,11 +656,6 @@ 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;

View File

@@ -3,7 +3,6 @@ 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';
@@ -16,10 +15,7 @@ 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);
@@ -121,7 +117,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={intl.formatMessage(messages['id.verification.context.loading.state'])} />; return <PageLoading srMessage="Loading verification status" />;
} }
if (!canVerify) { if (!canVerify) {

View File

@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'; import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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,10 +26,9 @@ 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 = () => { const IdVerificationPage = (props) => {
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);
@@ -79,33 +78,33 @@ const IdVerificationPage = () => {
</div> </div>
<ModalDialog <ModalDialog
isOpen={isModalOpen} isOpen={isModalOpen}
title={intl.formatMessage(messages['id.verification.privacy.title'])} title="Id modal"
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">
{intl.formatMessage(messages['id.verification.privacy.title'])} {props.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>
{intl.formatMessage( {props.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>{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p> <p>{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p>
<h6> <h6>
{intl.formatMessage( {props.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>
{intl.formatMessage( {props.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 },
)} )}
@@ -115,7 +114,7 @@ const IdVerificationPage = () => {
<ModalDialog.Footer className="p-2"> <ModalDialog.Footer className="p-2">
<ActionRow> <ActionRow>
<ModalDialog.CloseButton variant="link"> <ModalDialog.CloseButton variant="link">
{intl.formatMessage(messages['id.verification.privacy.modal.close.button'])} Close
</ModalDialog.CloseButton> </ModalDialog.CloseButton>
</ActionRow> </ActionRow>
</ModalDialog.Footer> </ModalDialog.Footer>
@@ -125,4 +124,8 @@ const IdVerificationPage = () => {
); );
}; };
export default connect(idVerificationSelector, {})(IdVerificationPage); IdVerificationPage.propTypes = {
intl: intlShape.isRequired,
};
export default connect(idVerificationSelector, {})(injectIntl(IdVerificationPage));

View File

@@ -1,12 +1,11 @@
import { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { intlShape } 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 }) => { const ImageFileUpload = ({ onFileChange, intl }) => {
const intl = useIntl();
const [error, setError] = useState(null); const [error, setError] = useState(null);
const errorTypes = { const errorTypes = {
invalidFileType: 'invalidFileType', invalidFileType: 'invalidFileType',
@@ -59,6 +58,7 @@ const ImageFileUpload = ({ onFileChange }) => {
ImageFileUpload.propTypes = { ImageFileUpload.propTypes = {
onFileChange: PropTypes.func.isRequired, onFileChange: PropTypes.func.isRequired,
intl: intlShape.isRequired,
}; };
export default ImageFileUpload; export default ImageFileUpload;

View File

@@ -1,147 +0,0 @@
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'),
});
});
});
});

View File

@@ -1,17 +1,17 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import React from 'react';
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>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6> <h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6>
<ol> <ol>
<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.step1'])}</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.step2'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step3'])}</li> <li>{props.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>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6> <h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6>
<ol> <ol>
<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.step1'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li> <li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li>
<ul> <ul>
<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.windows'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li> <li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li>
</ul> </ul>
<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.step3'])}</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.step4'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step5'])}</li> <li>{props.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>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6> <h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6>
<ol> <ol>
<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.step1'])}</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.step2'])}</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.step3'])}</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.step4'])}</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.step5'])}</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.step6'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step7'])}</li> <li>{props.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>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6> <h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6>
<ol> <ol>
<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.step1'])}</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.step2'])}</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.step3'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li> <li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li>
</ol> </ol>
</> </>
); );
@@ -68,7 +68,8 @@ export const EnableCameraDirectionsPanel = (props) => {
}; };
EnableCameraDirectionsPanel.propTypes = { EnableCameraDirectionsPanel.propTypes = {
intl: intlShape.isRequired,
browserName: PropTypes.string.isRequired, browserName: PropTypes.string.isRequired,
}; };
export default EnableCameraDirectionsPanel; export default injectIntl(EnableCameraDirectionsPanel);

View File

@@ -1,9 +1,9 @@
import { import React, {
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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities'; import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel'; import BasePanel from './BasePanel';
@@ -11,13 +11,12 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages'; import messages from '../IdVerification.messages';
const GetNameIdPanel = () => { const GetNameIdPanel = (props) => {
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 || '';
@@ -42,19 +41,19 @@ const GetNameIdPanel = () => {
return ( return (
<BasePanel <BasePanel
name={panelSlug} name={panelSlug}
title={intl.formatMessage(messages['id.verification.name.check.title'])} title={props.intl.formatMessage(messages['id.verification.name.check.title'])}
> >
<p> <p>
{intl.formatMessage(messages['id.verification.name.check.instructions'])} {props.intl.formatMessage(messages['id.verification.name.check.instructions'])}
</p> </p>
<p> <p>
{intl.formatMessage(messages['id.verification.name.check.mismatch.information'])} {props.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">
{intl.formatMessage(messages['id.verification.name.label'])} {props.intl.formatMessage(messages['id.verification.name.label'])}
</Form.Label> </Form.Label>
<Form.Control <Form.Control
controlId="photo-id-name" controlId="photo-id-name"
@@ -73,7 +72,7 @@ const GetNameIdPanel = () => {
data-testid="id-name-feedback-message" data-testid="id-name-feedback-message"
type="invalid" type="invalid"
> >
{intl.formatMessage(messages['id.verification.name.error'])} {props.intl.formatMessage(messages['id.verification.name.error'])}
</Form.Control.Feedback> </Form.Control.Feedback>
)} )}
</Form.Group> </Form.Group>
@@ -86,11 +85,15 @@ const GetNameIdPanel = () => {
data-testid="next-button" data-testid="next-button"
aria-disabled={!idPhotoName} aria-disabled={!idPhotoName}
> >
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default GetNameIdPanel; GetNameIdPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GetNameIdPanel);

View File

@@ -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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities'; import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel'; import BasePanel from './BasePanel';
@@ -8,46 +8,49 @@ 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 = () => { const IdContextPanel = (props) => {
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={intl.formatMessage(messages['id.verification.id.tips.title'])} title={props.intl.formatMessage(messages['id.verification.id.tips.title'])}
> >
<p>{intl.formatMessage(messages['id.verification.id.tips.description'])}</p> <p>{props.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>
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
</h6> </h6>
<p> <p>
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
</p> </p>
<ul> <ul>
<li> <li>
{intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])} {props.intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])}
</li> </li>
<li> <li>
{intl.formatMessage(messages['id.verification.id.tips.list.clear'])} {props.intl.formatMessage(messages['id.verification.id.tips.list.clear'])}
</li> </li>
</ul> </ul>
<img <img
src={exampleCard} src={exampleCard}
alt={intl.formatMessage(messages['id.verification.example.card.alt'])} alt={props.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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default IdContextPanel; IdContextPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(IdContextPanel);

View File

@@ -1,37 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } 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 = () => { const PortraitPhotoContextPanel = (props) => {
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={intl.formatMessage(messages['id.verification.photo.tips.title'])} title={props.intl.formatMessage(messages['id.verification.photo.tips.title'])}
> >
<p> <p>
{intl.formatMessage(messages['id.verification.photo.tips.description'])} {props.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>
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
</h6> </h6>
<p> <p>
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
</p> </p>
<ul className="mb-0"> <ul className="mb-0">
<li> <li>
{intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])}
</li> </li>
<li> <li>
{intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])} {props.intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])}
</li> </li>
</ul> </ul>
</div> </div>
@@ -39,11 +39,15 @@ const PortraitPhotoContextPanel = () => {
<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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default PortraitPhotoContextPanel; PortraitPhotoContextPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PortraitPhotoContextPanel);

View File

@@ -1,9 +1,9 @@
import { useEffect, useContext } from 'react'; import React, { 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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks'; import { useRedirect } from '../../hooks';
import { useNextPanelSlug } from '../routing-utilities'; import { useNextPanelSlug } from '../routing-utilities';
@@ -14,8 +14,7 @@ import { UnsupportedCameraDirectionsPanel } from './UnsupportedCameraDirectionsP
import messages from '../IdVerification.messages'; import messages from '../IdVerification.messages';
const RequestCameraAccessPanel = () => { const RequestCameraAccessPanel = (props) => {
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);
@@ -41,17 +40,17 @@ const RequestCameraAccessPanel = () => {
const getTitle = () => { const getTitle = () => {
if (mediaAccess === MEDIA_ACCESS.GRANTED) { if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return intl.formatMessage(messages['id.verification.camera.access.title.success']); return props.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 intl.formatMessage(messages['id.verification.camera.access.title.failed']); return props.intl.formatMessage(messages['id.verification.camera.access.title.failed']);
} }
return intl.formatMessage(messages['id.verification.camera.access.title']); return props.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}`}>
{intl.formatMessage(messages[returnText])} {props.intl.formatMessage(messages[returnText])}
</a> </a>
); );
@@ -68,13 +67,13 @@ const RequestCameraAccessPanel = () => {
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>{intl.formatMessage(messages['id.verification.camera.access.click.allow'])}</strong>, clickAllow: <strong>{props.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}>
{intl.formatMessage(messages['id.verification.camera.access.enable'])} {props.intl.formatMessage(messages['id.verification.camera.access.enable'])}
</button> </button>
</div> </div>
</div> </div>
@@ -83,11 +82,11 @@ const RequestCameraAccessPanel = () => {
{mediaAccess === MEDIA_ACCESS.GRANTED && ( {mediaAccess === MEDIA_ACCESS.GRANTED && (
<div> <div>
<p data-testid="camera-access-success"> <p data-testid="camera-access-success">
{intl.formatMessage(messages['id.verification.camera.access.success'])} {props.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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</div> </div>
@@ -96,9 +95,9 @@ const RequestCameraAccessPanel = () => {
{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">
{intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])} {props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])}
</p> </p>
<EnableCameraDirectionsPanel browserName={browserName} /> <EnableCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<div className="action-row"> <div className="action-row">
{returnLink} {returnLink}
</div> </div>
@@ -108,9 +107,9 @@ const RequestCameraAccessPanel = () => {
{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">
{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])} {props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])}
</p> </p>
<UnsupportedCameraDirectionsPanel browserName={browserName} /> <UnsupportedCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<div className="action-row"> <div className="action-row">
{returnLink} {returnLink}
</div> </div>
@@ -121,4 +120,8 @@ const RequestCameraAccessPanel = () => {
); );
}; };
export default RequestCameraAccessPanel; RequestCameraAccessPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(RequestCameraAccessPanel);

View File

@@ -1,8 +1,8 @@
import { useEffect, useContext } from 'react'; import React, { 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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } 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,8 +12,7 @@ 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 = () => { const ReviewRequirementsPanel = (props) => {
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);
@@ -42,7 +41,7 @@ const ReviewRequirementsPanel = () => {
profileDataManager, profileDataManager,
support: ( support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank"> <Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{intl.formatMessage(messages['id.verification.support'])} {props.intl.formatMessage(messages['id.verification.support'])}
</Hyperlink> </Hyperlink>
), ),
}} }}
@@ -55,17 +54,17 @@ const ReviewRequirementsPanel = () => {
return ( return (
<BasePanel <BasePanel
name={panelSlug} name={panelSlug}
title={intl.formatMessage(messages['id.verification.requirements.title'])} title={props.intl.formatMessage(messages['id.verification.requirements.title'])}
focusOnMount={false} focusOnMount={false}
> >
{renderManagedProfileMessage()} {renderManagedProfileMessage()}
<p> <p>
{intl.formatMessage(messages['id.verification.requirements.description'])} {props.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">
{intl.formatMessage(messages['id.verification.requirements.card.device.title'])} {props.intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
</h6> </h6>
<p className="mb-0"> <p className="mb-0">
<FormattedMessage <FormattedMessage
@@ -73,7 +72,7 @@ const ReviewRequirementsPanel = () => {
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>{intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>, allow: <strong>{props.intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>,
}} }}
/> />
</p> </p>
@@ -82,37 +81,37 @@ const ReviewRequirementsPanel = () => {
<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">
{intl.formatMessage(messages['id.verification.requirements.card.id.title'])} {props.intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
</h6> </h6>
<p className="mb-0"> <p className="mb-0">
{intl.formatMessage(messages['id.verification.requirements.card.id.text'])} {props.intl.formatMessage(messages['id.verification.requirements.card.id.text'])}
<img <img
src={exampleCard} src={exampleCard}
alt={intl.formatMessage(messages['id.verification.example.card.alt'])} alt={props.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">
{intl.formatMessage(messages['id.verification.privacy.title'])} {props.intl.formatMessage(messages['id.verification.privacy.title'])}
</h4> </h4>
<h6 aria-level="3"> <h6 aria-level="3">
{intl.formatMessage( {props.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>
{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])} {props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
</p> </p>
<h6 aria-level="3"> <h6 aria-level="3">
{intl.formatMessage( {props.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>
{intl.formatMessage( {props.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 },
)} )}
@@ -120,11 +119,15 @@ const ReviewRequirementsPanel = () => {
<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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default ReviewRequirementsPanel; ReviewRequirementsPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ReviewRequirementsPanel);

View File

@@ -1,7 +1,7 @@
import { useContext, useEffect } from 'react'; import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks'; import { useRedirect } from '../../hooks';
@@ -10,11 +10,10 @@ import messages from '../IdVerification.messages';
import BasePanel from './BasePanel'; import BasePanel from './BasePanel';
const SubmittedPanel = () => { const SubmittedPanel = (props) => {
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', {
@@ -26,20 +25,24 @@ const SubmittedPanel = () => {
return ( return (
<BasePanel <BasePanel
name={panelSlug} name={panelSlug}
title={intl.formatMessage(messages['id.verification.submitted.title'])} title={props.intl.formatMessage(messages['id.verification.submitted.title'])}
> >
<p> <p>
{intl.formatMessage(messages['id.verification.submitted.text'])} {props.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"
> >
{intl.formatMessage(messages[returnText])} {props.intl.formatMessage(messages[returnText])}
</a> </a>
</BasePanel> </BasePanel>
); );
}; };
export default SubmittedPanel; SubmittedPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SubmittedPanel);

View File

@@ -1,10 +1,10 @@
import { useState, useContext, useEffect } from 'react'; import React, { 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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } 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,8 +16,7 @@ import messages from '../IdVerification.messages';
import CameraHelpWithUpload from '../CameraHelpWithUpload'; import CameraHelpWithUpload from '../CameraHelpWithUpload';
import SupportedMediaTypes from '../SupportedMediaTypes'; import SupportedMediaTypes from '../SupportedMediaTypes';
const SummaryPanel = () => { const SummaryPanel = (props) => {
const intl = useIntl();
const panelSlug = 'summary'; const panelSlug = 'summary';
const nextPanelSlug = useNextPanelSlug(panelSlug); const nextPanelSlug = useNextPanelSlug(panelSlug);
const { const {
@@ -52,7 +51,7 @@ const SummaryPanel = () => {
profileDataManager, profileDataManager,
support: ( support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank"> <Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{intl.formatMessage(messages['id.verification.support'])} {props.intl.formatMessage(messages['id.verification.support'])}
</Hyperlink> </Hyperlink>
), ),
}} }}
@@ -91,11 +90,12 @@ const SummaryPanel = () => {
}; };
return ( return (
<Button <Button
title="Confirmation"
disabled={isSubmitting} disabled={isSubmitting}
onClick={handleClick} onClick={handleClick}
data-testid="submit-button" data-testid="submit-button"
> >
{intl.formatMessage(messages['id.verification.review.confirm'])} {props.intl.formatMessage(messages['id.verification.review.confirm'])}
</Button> </Button>
); );
}; };
@@ -103,18 +103,18 @@ const SummaryPanel = () => {
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 intl.formatMessage(messages['id.verification.submission.alert.error.face']); return props.intl.formatMessage(messages['id.verification.submission.alert.error.face']);
} }
if (submissionError.message.includes('Photo ID image')) { if (submissionError.message.includes('Photo ID image')) {
return intl.formatMessage(messages['id.verification.submission.alert.error.id']); return props.intl.formatMessage(messages['id.verification.submission.alert.error.id']);
} }
if (submissionError.message.includes('Name')) { if (submissionError.message.includes('Name')) {
return intl.formatMessage(messages['id.verification.submission.alert.error.name']); return props.intl.formatMessage(messages['id.verification.submission.alert.error.name']);
} }
if (submissionError.message.includes('unsupported format')) { if (submissionError.message.includes('unsupported format')) {
return ( return (
<> <>
{intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])} {props.intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])}
<SupportedMediaTypes /> <SupportedMediaTypes />
</> </>
); );
@@ -131,7 +131,7 @@ const SummaryPanel = () => {
values={{ values={{
support_link: ( support_link: (
<Alert.Link href="https://support.edx.org/hc/en-us"> <Alert.Link href="https://support.edx.org/hc/en-us">
{intl.formatMessage( {props.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 = () => {
return ( return (
<BasePanel <BasePanel
name={panelSlug} name={panelSlug}
title={intl.formatMessage(messages['id.verification.review.title'])} title={props.intl.formatMessage(messages['id.verification.review.title'])}
> >
{submissionError && ( {submissionError && (
<Alert <Alert
@@ -158,17 +158,17 @@ const SummaryPanel = () => {
</Alert> </Alert>
)} )}
<p> <p>
{intl.formatMessage(messages['id.verification.review.description'])} {props.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">
{intl.formatMessage(messages['id.verification.review.portrait.label'])} {props.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={intl.formatMessage(messages['id.verification.review.portrait.alt'])} alt={props.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 = () => {
state={{ fromSummary: true }} state={{ fromSummary: true }}
data-testid="portrait-retake" data-testid="portrait-retake"
> >
{intl.formatMessage(messages['id.verification.review.portrait.retake'])} {props.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">
{intl.formatMessage(messages['id.verification.review.id.label'])} {props.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={intl.formatMessage(messages['id.verification.review.id.alt'])} alt={props.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 = () => {
state={{ fromSummary: true }} state={{ fromSummary: true }}
data-testid="id-retake" data-testid="id-retake"
> >
{intl.formatMessage(messages['id.verification.review.id.retake'])} {props.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">
{intl.formatMessage(messages['id.verification.name.label'])} {props.intl.formatMessage(messages['id.verification.name.label'])}
</label> </label>
{renderManagedProfileMessage()} {renderManagedProfileMessage()}
<div className="d-flex"> <div className="d-flex">
@@ -237,4 +237,8 @@ const SummaryPanel = () => {
); );
}; };
export default SummaryPanel; SummaryPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SummaryPanel);

View File

@@ -1,6 +1,6 @@
import { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities'; import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel'; import BasePanel from './BasePanel';
@@ -14,12 +14,11 @@ import ImageFileUpload from '../ImageFileUpload';
import CollapsibleImageHelp from '../CollapsibleImageHelp'; import CollapsibleImageHelp from '../CollapsibleImageHelp';
import SupportedMediaTypes from '../SupportedMediaTypes'; import SupportedMediaTypes from '../SupportedMediaTypes';
const TakeIdPhotoPanel = () => { const TakeIdPhotoPanel = (props) => {
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
@@ -31,31 +30,31 @@ const TakeIdPhotoPanel = () => {
name={panelSlug} name={panelSlug}
focusOnMount={!mounted} focusOnMount={!mounted}
title={useCameraForId title={useCameraForId
? intl.formatMessage(messages['id.verification.id.photo.title.camera']) ? props.intl.formatMessage(messages['id.verification.id.photo.title.camera'])
: intl.formatMessage(messages['id.verification.id.photo.title.upload'])} : props.intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
> >
<div> <div>
{idPhotoFile && !useCameraForId && ( {idPhotoFile && !useCameraForId && (
<ImagePreview <ImagePreview
src={idPhotoFile} src={idPhotoFile}
alt={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
/> />
)} )}
{useCameraForId ? ( {useCameraForId ? (
<div> <div>
<p> <p>
{intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])} {props.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">
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])} {props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
<SupportedMediaTypes /> <SupportedMediaTypes />
</p> </p>
<ImageFileUpload onFileChange={setIdPhotoFile} /> <ImageFileUpload onFileChange={setIdPhotoFile} intl={props.intl} />
</div> </div>
)} )}
</div> </div>
@@ -63,11 +62,15 @@ const TakeIdPhotoPanel = () => {
<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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default TakeIdPhotoPanel; TakeIdPhotoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TakeIdPhotoPanel);

View File

@@ -1,6 +1,6 @@
import { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities'; import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel'; import BasePanel from './BasePanel';
@@ -10,12 +10,11 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages'; import messages from '../IdVerification.messages';
const TakePortraitPhotoPanel = () => { const TakePortraitPhotoPanel = (props) => {
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
@@ -26,22 +25,26 @@ const TakePortraitPhotoPanel = () => {
<BasePanel <BasePanel
name={panelSlug} name={panelSlug}
focusOnMount={!mounted} focusOnMount={!mounted}
title={intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])} title={props.intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
> >
<div> <div>
<p> <p>
{intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])} {props.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">
{intl.formatMessage(messages['id.verification.next'])} {props.intl.formatMessage(messages['id.verification.next'])}
</Link> </Link>
</div> </div>
</BasePanel> </BasePanel>
); );
}; };
export default TakePortraitPhotoPanel; TakePortraitPhotoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TakePortraitPhotoPanel);

View File

@@ -1,21 +1,19 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; 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 UnsupportedCameraDirectionsPanel = (props) => { export const UnsupportedCameraDirectionsPanel = (props) => (
const intl = useIntl(); <>
return ( {props.browserName === 'Chrome' && <span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>}
<> <span> </span>
{props.browserName === 'Chrome' && <span>{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>} <span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])}</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 UnsupportedCameraDirectionsPanel; export default injectIntl(UnsupportedCameraDirectionsPanel);

View File

@@ -4,13 +4,16 @@ 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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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: '',
}; };
@@ -24,7 +27,7 @@ describe('AccessBlocked', () => {
await act(async () => render(( await act(async () => render((
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<AccessBlocked {...defaultProps} /> <IntlAccessBlocked {...defaultProps} />
</IntlProvider> </IntlProvider>
</Router> </Router>
))); )));
@@ -40,7 +43,7 @@ describe('AccessBlocked', () => {
await act(async () => render(( await act(async () => render((
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<AccessBlocked {...defaultProps} /> <IntlAccessBlocked {...defaultProps} />
</IntlProvider> </IntlProvider>
</Router> </Router>
))); )));
@@ -56,7 +59,7 @@ describe('AccessBlocked', () => {
await act(async () => render(( await act(async () => render((
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<AccessBlocked {...defaultProps} /> <IntlAccessBlocked {...defaultProps} />
</IntlProvider> </IntlProvider>
</Router> </Router>
))); )));

View File

@@ -5,7 +5,6 @@ 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';
@@ -182,99 +181,4 @@ 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,
}));
});
});
}); });

View File

@@ -1,8 +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, screen, act, render, cleanup, screen, act,
} from '@testing-library/react'; } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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';
@@ -16,7 +17,11 @@ 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(),
@@ -31,7 +36,7 @@ describe('CollapsibleImageHelpPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<CollapsibleImageHelp /> <IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -51,7 +56,7 @@ describe('CollapsibleImageHelpPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<CollapsibleImageHelp /> <IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -1,11 +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 } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import IdVerificationPageSlot from '../../plugin-slots/IdVerificationPageSlot'; import IdVerificationPageSlot from '../../plugin-slots/IdVerificationPageSlot';
import * as selectors from '../data/selectors'; import * as selectors from '../data/selectors';
@@ -46,18 +47,22 @@ jest.mock('../panels/SubmittedPanel', () => function SubmittedPanelMock() {
return <></>; return <></>;
}); });
const IntlIdVerificationPage = injectIntl(IdVerificationPageSlot);
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}>
<IdVerificationPageSlot /> <IntlIdVerificationPage {...props} />
</Provider> </Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -73,7 +78,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}> <Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<Provider store={store}> <Provider store={store}>
<IdVerificationPageSlot /> <IntlIdVerificationPage {...props} />
</Provider> </Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -88,7 +93,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}> <Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<Provider store={store}> <Provider store={store}>
<IdVerificationPageSlot /> <IntlIdVerificationPage {...props} />
</Provider> </Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -102,7 +107,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}> <Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<Provider store={store}> <Provider store={store}>
<IdVerificationPageSlot /> <IntlIdVerificationPage {...props} />
</Provider> </Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -1,9 +1,10 @@
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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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';
@@ -12,7 +13,13 @@ 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,
@@ -30,7 +37,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}>
<GetNameIdPanel /> <IntlGetNameIdPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</VerifiedNameContext.Provider> </VerifiedNameContext.Provider>
</IntlProvider> </IntlProvider>

View File

@@ -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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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,7 +12,13 @@ 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,
@@ -27,7 +33,7 @@ describe('IdContextPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<IdContextPanel /> <IntlIdContextPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -43,7 +49,7 @@ describe('IdContextPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<IdContextPanel /> <IntlIdContextPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -1,9 +1,10 @@
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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel'; import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel';
import IdVerificationContext from '../../IdVerificationContext'; import IdVerificationContext from '../../IdVerificationContext';
@@ -11,7 +12,13 @@ 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(() => {
@@ -23,7 +30,7 @@ describe('PortraitPhotoContextPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<PortraitPhotoContextPanel /> <IntlPortraitPhotoContextPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -39,7 +46,7 @@ describe('PortraitPhotoContextPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<PortraitPhotoContextPanel /> <IntlPortraitPhotoContextPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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,7 +15,13 @@ 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(),
@@ -32,7 +38,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -48,7 +54,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -67,7 +73,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -83,7 +89,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -100,7 +106,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -117,7 +123,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -133,7 +139,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -149,7 +155,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -165,7 +171,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -182,7 +188,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -199,7 +205,7 @@ describe('RequestCameraAccessPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<RequestCameraAccessPanel /> <IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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,7 +12,13 @@ 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 () => {
@@ -20,7 +26,7 @@ describe('ReviewRequirementsPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={context}> <IdVerificationContext.Provider value={context}>
<ReviewRequirementsPanel /> <IntlReviewRequirementsPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -1,9 +1,10 @@
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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext'; import IdVerificationContext from '../../IdVerificationContext';
import SubmittedPanel from '../../panels/SubmittedPanel'; import SubmittedPanel from '../../panels/SubmittedPanel';
@@ -11,7 +12,13 @@ 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',
@@ -36,7 +43,7 @@ describe('SubmittedPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<SubmittedPanel /> <IntlSubmittedPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -52,7 +59,7 @@ describe('SubmittedPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<SubmittedPanel /> <IntlSubmittedPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -68,7 +75,7 @@ describe('SubmittedPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<SubmittedPanel /> <IntlSubmittedPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -1,10 +1,11 @@
/* 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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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';
@@ -17,7 +18,13 @@ 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',
@@ -35,7 +42,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}>
<SummaryPanel /> <IntlSummaryPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</VerifiedNameContext.Provider> </VerifiedNameContext.Provider>
</IntlProvider> </IntlProvider>

View File

@@ -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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, 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,7 +13,13 @@ 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,
@@ -31,7 +37,7 @@ describe('TakeIdPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakeIdPhotoPanel /> <IntlTakeIdPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -46,7 +52,7 @@ describe('TakeIdPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakeIdPhotoPanel /> <IntlTakeIdPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -64,7 +70,7 @@ describe('TakeIdPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakeIdPhotoPanel /> <IntlTakeIdPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -79,7 +85,7 @@ describe('TakeIdPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakeIdPhotoPanel /> <IntlTakeIdPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -92,24 +98,4 @@ 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();
});
}); });

View File

@@ -1,9 +1,10 @@
/* 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 { IntlProvider } from '@edx/frontend-platform/i18n'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext'; import IdVerificationContext from '../../IdVerificationContext';
import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel'; import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel';
@@ -15,7 +16,13 @@ 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,
@@ -32,7 +39,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakePortraitPhotoPanel /> <IntlTakePortraitPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -47,7 +54,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakePortraitPhotoPanel /> <IntlTakePortraitPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>
@@ -66,7 +73,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router> <Router>
<IntlProvider locale="en"> <IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}> <IdVerificationContext.Provider value={contextValue}>
<TakePortraitPhotoPanel /> <IntlTakePortraitPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider> </IdVerificationContext.Provider>
</IntlProvider> </IntlProvider>
</Router> </Router>

View File

@@ -65,8 +65,7 @@ initialize({
config: () => { config: () => {
mergeConfig({ mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL, SUPPORT_URL: process.env.SUPPORT_URL,
SHOW_PUSH_CHANNEL: process.env.SHOW_PUSH_CHANNEL || false, SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_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 || '[]'),

View File

@@ -1,8 +1,10 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
$fa-font-path: "~font-awesome/fonts"; $fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome"; @import "~font-awesome/scss/font-awesome";
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer"; @import "~@edx/frontend-component-footer/dist/footer";
@@ -118,7 +120,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: var(--pgn-color-light-300) !important; background-color: $light-300 !important;
} }
@@ -136,7 +138,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 var(--pgn-color-light-500) !important; border: 1px solid $light-500 !important;
} }
.dropdown-item { .dropdown-item {

View File

@@ -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, disabled = false, email, onToggle, emailCadence, notificationType,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [isOpen, open, close] = useToggle(false); const [isOpen, open, close] = useToggle(false);
@@ -26,10 +26,9 @@ 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} disabled={!email || updatePreferencesStatus === LOADING_STATUS}
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"
@@ -55,7 +54,6 @@ 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();
@@ -75,7 +73,6 @@ 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);

View File

@@ -0,0 +1,73 @@
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;

View File

@@ -13,10 +13,7 @@ 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 { import { selectAppPreferences, selectSelectedCourseId, selectUpdatePreferencesStatus } from './data/selectors';
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,
@@ -25,19 +22,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) { if (notificationChannel === EMAIL_CADENCE && courseId) {
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) {
@@ -62,13 +59,14 @@ 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, appId]); }, [appPreferences, getValue, getEmailCadence, dispatch, courseId, appId]);
const renderPreference = (preference) => ( const renderPreference = (preference) => (
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && ( (preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
@@ -86,8 +84,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 || nonEditable[preference.id]?.includes(channel)} disabled={updatePreferencesStatus === LOADING_STATUS}
id={`toggle-${preference.id}-${channel}`} id={`${preference.id}-${channel}`}
className="my-1" className="my-1"
/> />
{channel === EMAIL && ( {channel === EMAIL && (
@@ -96,7 +94,6 @@ 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>

View File

@@ -9,21 +9,23 @@ 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 { fetchNotificationPreferences } from './data/thunks'; import { fetchCourseNotificationPreferences } from './data/thunks';
import { LOADING_STATUS } from '../constants'; import { LOADING_STATUS } from '../constants';
import { import {
selectNotificationPreferencesStatus, selectPreferenceAppsId, selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId, selectSelectedCourseId,
} 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; const isLoading = notificationStatus === LOADING_STATUS || courseStatus === LOADING_STATUS;
const preferencesList = useMemo(() => ( const preferencesList = useMemo(() => (
preferenceAppsIds.map(appId => ( preferenceAppsIds.map(appId => (
@@ -32,8 +34,8 @@ const NotificationPreferences = () => {
), [preferenceAppsIds]); ), [preferenceAppsIds]);
useEffect(() => { useEffect(() => {
dispatch(fetchNotificationPreferences()); dispatch(fetchCourseNotificationPreferences(courseId));
}, [dispatch]); }, [courseId, dispatch]);
if (preferenceAppsIds.length === 0) { if (preferenceAppsIds.length === 0) {
return null; return null;

View File

@@ -3,7 +3,6 @@ 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';
@@ -11,10 +10,6 @@ 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';
@@ -56,7 +51,7 @@ const defaultPreferences = {
appId: 'coursework', appId: 'coursework',
web: false, web: false,
push: false, push: false,
email: true, email: false,
coreNotificationTypes: [], coreNotificationTypes: [],
}, },
{ {
@@ -71,7 +66,7 @@ const defaultPreferences = {
nonEditable: { nonEditable: {
discussion: { discussion: {
core: [ core: [
'web', 'email', 'web',
], ],
}, },
}, },
@@ -110,14 +105,10 @@ 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(() => ({
@@ -148,126 +139,23 @@ describe('Notification Preferences', () => {
expect(screen.queryAllByTestId('notification-preference')).toHaveLength(4); expect(screen.queryAllByTestId('notification-preference')).toHaveLength(4);
}); });
it('update account preference on click', async () => { it('update preference on click', async () => {
store = setupStore({ const wrapper = await render(notificationPreferences(store));
...defaultPreferences, const element = wrapper.container.querySelector('#core-web');
status: SUCCESS_STATUS, expect(element).not.toBeChecked();
});
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('test non editable', async () => { it('update account preference on click', 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));
expect(screen.getByTestId('toggle-core-web')).toBeDisabled(); const element = screen.getByTestId('core-web');
expect(screen.getByTestId('toggle-core-email')).toBeDisabled(); await fireEvent.click(element);
expect(screen.getAllByTestId('email-cadence-button')[0]).toBeDisabled(); expect(mockDispatch).toHaveBeenCalled();
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();
});
}); });
}); });

View File

@@ -4,8 +4,9 @@ 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 { selectShowPreferences } from './data/selectors'; import { selectSelectedCourseId, 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';
@@ -13,6 +14,7 @@ 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 && (
@@ -20,9 +22,13 @@ 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', weeklyTime: '22:00 UTC', dailyTime: '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">
@@ -36,7 +42,8 @@ const NotificationSettings = () => {
{intl.formatMessage(messages.notificationPreferenceGuideLink)} {intl.formatMessage(messages.notificationPreferenceGuideLink)}
</Hyperlink> </Hyperlink>
</div> </div>
<NotificationPreferences /> <NotificationCoursesDropdown />
<NotificationPreferences courseId={courseId} />
<div className="border border-light-700 my-6" /> <div className="border border-light-700 my-6" />
</Container> </Container>
) )

View File

@@ -2,15 +2,19 @@ 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 = (payload, showPreferences, isPreferenceUpdate) => dispatch => { export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => (
dispatch({ dispatch({
type: Actions.FETCHED_PREFERENCES, payload, showPreferences, isPreferenceUpdate, type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference,
}); })
}; );
export const fetchNotificationPreferenceFetching = () => dispatch => ( export const fetchNotificationPreferenceFetching = () => dispatch => (
dispatch({ type: Actions.FETCHING_PREFERENCES }) dispatch({ type: Actions.FETCHING_PREFERENCES })
@@ -20,6 +24,22 @@ 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,

View File

@@ -1,7 +1,6 @@
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';

View File

@@ -9,9 +9,15 @@ 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: {},
@@ -20,9 +26,35 @@ export const defaultState = {
const notificationPreferencesReducer = (state = defaultState, action = {}) => { const notificationPreferencesReducer = (state = defaultState, action = {}) => {
const { const {
appId, notificationChannel, preferenceName, value, courseId, 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,
@@ -37,7 +69,7 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
case Actions.FETCHED_PREFERENCES: case Actions.FETCHED_PREFERENCES:
{ {
const { preferences } = state; const { preferences } = state;
if (action.isPreferenceUpdate) { if (action.isAccountPreference) {
normalizeAccountPreferences(preferences, action.payload); normalizeAccountPreferences(preferences, action.payload);
} }
@@ -49,7 +81,6 @@ 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:
@@ -64,6 +95,14 @@ 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,

View File

@@ -10,6 +10,7 @@ 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 }],
@@ -27,6 +28,53 @@ 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,
@@ -35,6 +83,7 @@ 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,
}); });
}); });
@@ -49,6 +98,7 @@ describe('notification-preferences reducer', () => {
); );
expect(result.preferences).toEqual({ expect(result.preferences).toEqual({
status, status,
selectedCourse: '',
preferences: [], preferences: [],
apps: [], apps: [],
nonEditable: {}, nonEditable: {},

View File

@@ -1,6 +1,3 @@
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
); );
@@ -13,6 +10,20 @@ 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)
); );
@@ -43,6 +54,14 @@ 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
); );

View File

@@ -2,12 +2,37 @@ 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 getNotificationPreferences = async () => { export const getCourseNotificationPreferences = async (courseId) => {
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`; const url = `${getConfig().LMS_BASE_URL}/api/notifications/configurations/${courseId}`;
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,
@@ -22,7 +47,7 @@ export const postPreferenceToggle = async (
value, value,
emailCadence, emailCadence,
}); });
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`; const url = `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update-all/`;
const { data } = await getAuthenticatedHttpClient().put(url, patchData); const { data } = await getAuthenticatedHttpClient().post(url, patchData);
return data; return data;
}; };

View File

@@ -1,73 +0,0 @@
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);
});
});
});

View File

@@ -4,7 +4,7 @@ import {
fetchNotificationPreferenceSuccess, fetchNotificationPreferenceSuccess,
fetchNotificationPreferenceFailed, fetchNotificationPreferenceFailed,
} from './actions'; } from './actions';
import { postPreferenceToggle } from './service'; import { patchPreferenceToggle, postPreferenceToggle } from './service';
import { EMAIL } from './constants'; import { EMAIL } from './constants';
jest.mock('./service', () => ({ jest.mock('./service', () => ({
@@ -60,9 +60,37 @@ describe('updatePreferenceToggle', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should update preference globally', async () => { it('should update preference for a course-specific notification', 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,
@@ -87,22 +115,23 @@ describe('updatePreferenceToggle', () => {
}); });
it('should handle email preferences separately', async () => { it('should handle email preferences separately', async () => {
postPreferenceToggle.mockResolvedValue({ data: mockData }); patchPreferenceToggle.mockResolvedValue({ data: mockData });
await updatePreferenceToggle(notificationApp, notificationType, EMAIL, value, emailCadence)(dispatch); await updatePreferenceToggle(courseId, notificationApp, notificationType, EMAIL, value, emailCadence)(dispatch);
expect(postPreferenceToggle).toHaveBeenCalledWith( expect(patchPreferenceToggle).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 () => {
postPreferenceToggle.mockRejectedValue(new Error('Network Error')); patchPreferenceToggle.mockRejectedValue(new Error('Network Error'));
await updatePreferenceToggle( await updatePreferenceToggle(
courseId,
notificationApp, notificationApp,
notificationType, notificationType,
notificationChannel, notificationChannel,

View File

@@ -2,16 +2,42 @@ 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 {
getNotificationPreferences, getCourseList,
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,
@@ -28,8 +54,13 @@ export const normalizeAccountPreferences = (originalData, updateInfo) => {
return originalData; return originalData;
}; };
const normalizePreferences = (responseData) => { const normalizePreferences = (responseData, courseId) => {
const preferences = responseData.data; let preferences;
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) => ({
@@ -66,20 +97,41 @@ const normalizePreferences = (responseData) => {
return normalizedPreferences; return normalizedPreferences;
}; };
export const fetchNotificationPreferences = () => ( export const fetchCourseList = (page, pageSize) => (
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 = camelCaseObject(await getNotificationPreferences()); const data = await getCourseNotificationPreferences(courseId);
const normalizedData = normalizePreferences(data); const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
dispatch(fetchNotificationPreferenceSuccess(normalizedData, data.showPreferences)); dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} 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,
@@ -97,35 +149,49 @@ export const updatePreferenceToggle = (
)); ));
// Function to handle data normalization and dispatching success // Function to handle data normalization and dispatching success
const handleSuccessResponse = (data) => { const handleSuccessResponse = (data, isGlobal = false) => {
const processedData = camelCaseObject(data); const processedData = courseId
? normalizePreferences(camelCaseObject(data), courseId)
: camelCaseObject(data);
dispatch(fetchNotificationPreferenceSuccess(processedData, processedData.showPreferences, true)); dispatch(fetchNotificationPreferenceSuccess(courseId, processedData, isGlobal));
return processedData; return processedData;
}; };
// Function to toggle preference based on context // Function to toggle preference based on context (course-specific or global)
const togglePreference = async (channel, toggleValue, cadence) => postPreferenceToggle( const togglePreference = async (channel, toggleValue, cadence) => {
notificationApp, if (courseId) {
notificationType, return patchPreferenceToggle(
channel, courseId,
channel === EMAIL_CADENCE ? undefined : toggleValue, notificationApp,
cadence, notificationType,
); 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); handleSuccessResponse(data, !courseId);
// 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,
value, courseId ? undefined : value,
EMAIL_CADENCE_PREFERENCES.DAILY, EMAIL_CADENCE_PREFERENCES.DAILY,
); );
handleSuccessResponse(emailCadenceData); handleSuccessResponse(emailCadenceData, !courseId);
} }
} catch (errors) { } catch (errors) {
dispatch(updatePreferenceValue( dispatch(updatePreferenceValue(

View File

@@ -1,12 +1,6 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { parseEnvBoolean } from '../../utils'; export const notificationChannels = () => ({ WEB: 'web', ...(getConfig().SHOW_EMAIL_CHANNEL === 'true' && { EMAIL: 'email' }) });
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);

View File

@@ -29,7 +29,6 @@ const messages = defineMessages({
courseUpdates {Course updates} courseUpdates {Course updates}
oraStaffNotifications {New ORA submission for staff grading} 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',
@@ -91,9 +90,14 @@ 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 email notifications are sent at {dailyTime}. Weekly email notifications are sent every Sunday at {weeklyTime}.', defaultMessage: 'Daily notifications are delivered at {dailyTime}. Weekly notifications are delivered at {weeklyTime}.',
description: 'Notification cadence description', description: 'Notification cadence description',
}, },
notificationDefaultInfo: { notificationDefaultInfo: {

View File

@@ -1,8 +1,6 @@
import 'core-js/stable'; import 'core-js/stable';
import 'regenerator-runtime/runtime'; import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { initialize, mergeConfig } from '@edx/frontend-platform';
import { MockAuthService } from '@edx/frontend-platform/auth';
import MockedPluginSlot from './tests/MockedPluginSlot'; import MockedPluginSlot from './tests/MockedPluginSlot';
@@ -11,39 +9,3 @@ jest.mock('@openedx/frontend-plugin-framework', () => ({
Plugin: () => 'Plugin', Plugin: () => 'Plugin',
PluginSlot: MockedPluginSlot, PluginSlot: MockedPluginSlot,
})); }));
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL || 'https://support.example.com',
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_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'),
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK || 'https://support.example.com/password-reset',
LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL || 'https://support.example.com/feedback',
}, 'App loadConfig override handler');
initialize({
handlers: {
config: () => {
mergeConfig({
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
},
},
messages: [],
authService: MockAuthService,
});
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

View File

@@ -38,13 +38,3 @@ export function getMostRecentApprovedOrPendingVerifiedName(verifiedNames) {
return applicableName; return applicableName;
} }
/**
* Parse an environment variable string value to a boolean.
* @param {string} value the environment variable string value
* @returns {boolean} the parsed boolean value
*/
export const parseEnvBoolean = (value) => {
if (!value) { return false; }
return String(value).toLowerCase() === 'true';
};