Compare commits
15 Commits
release/te
...
sundasAcco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42342c2b18 | ||
|
|
5a5de29dea | ||
|
|
5381719477 | ||
|
|
235dd9c462 | ||
|
|
301797aca3 | ||
|
|
27c686fbe3 | ||
|
|
45bcfddab6 | ||
|
|
45b0734e83 | ||
|
|
8db7c54119 | ||
|
|
f04fddd8c0 | ||
|
|
84de80eb7f | ||
|
|
7618004054 | ||
|
|
3b2cf2717e | ||
|
|
dbb374860d | ||
|
|
aeff548b82 |
3
.env
3
.env
@@ -15,7 +15,7 @@ LOGO_WHITE_URL=''
|
||||
SHOW_EMAIL_CHANNEL=''
|
||||
LOGOUT_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
NODE_ENV='production'
|
||||
NODE_ENV=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
@@ -33,4 +33,3 @@ MFE_CONFIG_API_URL=
|
||||
PASSWORD_RESET_SUPPORT_LINK=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||
|
||||
@@ -34,4 +34,3 @@ MFE_CONFIG_API_URL=
|
||||
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||
|
||||
@@ -31,4 +31,3 @@ APP_ID=
|
||||
MFE_CONFIG_API_URL=
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||
|
||||
@@ -3,4 +3,3 @@ dist/
|
||||
node_modules/
|
||||
__mocks__/
|
||||
__snapshots__/
|
||||
src/i18n/messages/
|
||||
|
||||
18
Makefile
18
Makefile
@@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -17,11 +19,6 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
|
||||
npm run $(*)
|
||||
|
||||
.PHONY: requirements
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements: ## install ci requirements
|
||||
npm ci
|
||||
|
||||
@@ -41,17 +38,6 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
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:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
|
||||
40
README.rst
40
README.rst
@@ -25,13 +25,40 @@ Getting Started
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
`Tutor`_ is currently recommended as a development environment for your
|
||||
new MFE. Please refer
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
|
||||
|
||||
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
|
||||
|
||||
2. Start up Devstack, if it's not already started.
|
||||
|
||||
3. Log in to Devstack (http://localhost:18000/login )
|
||||
|
||||
4. Within this project, install requirements and start the development server:
|
||||
|
||||
.. code-block::
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1997
|
||||
|
||||
5. Once the dev server is up, visit http://localhost:1997 to access the MFE
|
||||
|
||||
.. image:: ./docs/images/localhost_preview.png
|
||||
|
||||
Plugins
|
||||
=======
|
||||
@@ -42,7 +69,7 @@ The parts of this MFE that can be customized in that manner are documented `here
|
||||
Environment Variables/Setup Notes
|
||||
=================================
|
||||
|
||||
This MFE is configured via the ``frontend-platform`` configuration module. For more information on MFE configuration see the `Configuration documentation`_.
|
||||
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
||||
|
||||
The account settings micro-frontend also supports the following additional variable:
|
||||
|
||||
@@ -75,9 +102,8 @@ Example build syntax with a single environment variable:
|
||||
|
||||
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
|
||||
|
||||
For more information see the document: `Configuration documentation`_
|
||||
|
||||
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
|
||||
For more information see the document: `Micro-frontend applications in Open
|
||||
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
@@ -12,8 +12,7 @@ metadata:
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:2u-infinity
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
6
openedx.yaml
Normal file
6
openedx.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
|
||||
|
||||
nick: acct
|
||||
oeps: {}
|
||||
openedx-release: {ref: master}
|
||||
7380
package-lock.json
generated
7380
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -29,23 +29,23 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.3",
|
||||
"@edx/frontend-component-header": "^5.6.0",
|
||||
"@edx/frontend-platform": "8.1.2",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "0.2.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.2.2",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "22.9.0",
|
||||
"@tensorflow-models/blazeface": "0.1.0",
|
||||
"@tensorflow/tfjs-converter": "4.22.0",
|
||||
"@tensorflow/tfjs-core": "4.22.0",
|
||||
"bowser": "2.11.0",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.41.0",
|
||||
"core-js": "3.38.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"form-urlencoded": "6.1.5",
|
||||
"formdata-polyfill": "4.0.10",
|
||||
@@ -60,12 +60,12 @@
|
||||
"lodash.pick": "4.4.0",
|
||||
"lodash.pickby": "4.6.0",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"long": "5.3.2",
|
||||
"long": "5.2.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.14.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"qs": "6.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "^6.25.1",
|
||||
@@ -83,12 +83,12 @@
|
||||
"universal-cookie": "7.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@openedx/frontend-build": "14.1.5",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "1.5.5"
|
||||
}
|
||||
|
||||
@@ -47,13 +47,11 @@ import {
|
||||
COPPA_COMPLIANCE_YEAR,
|
||||
WORK_EXPERIENCE_OPTIONS,
|
||||
getStatesList,
|
||||
FIELD_LABELS,
|
||||
} from './data/constants';
|
||||
import { fetchSiteLanguages } from './site-language';
|
||||
import { fetchCourseList } from '../notification-preferences/data/thunks';
|
||||
import NotificationSettings from '../notification-preferences/NotificationSettings';
|
||||
import { withLocation, withNavigate } from './hoc';
|
||||
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
constructor(props, context) {
|
||||
@@ -68,7 +66,6 @@ class AccountSettingsPage extends React.Component {
|
||||
'#basic-information': React.createRef(),
|
||||
'#profile-information': React.createRef(),
|
||||
'#social-media': React.createRef(),
|
||||
'#notifications': React.createRef(),
|
||||
'#site-preferences': React.createRef(),
|
||||
'#linked-accounts': React.createRef(),
|
||||
'#delete-account': React.createRef(),
|
||||
@@ -159,19 +156,17 @@ class AccountSettingsPage extends React.Component {
|
||||
})),
|
||||
}));
|
||||
|
||||
canDeleteAccount = () => {
|
||||
const { committedValues } = this.props;
|
||||
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
|
||||
};
|
||||
|
||||
removeDisabledCountries = (countryList) => {
|
||||
const { countriesCodesList, committedValues } = this.props;
|
||||
const committedCountry = committedValues?.country;
|
||||
const { disabledCountries, committedValues } = this.props;
|
||||
|
||||
if (!countriesCodesList.length) {
|
||||
if (!disabledCountries.length) {
|
||||
return countryList;
|
||||
}
|
||||
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
|
||||
|
||||
return countryList.filter(({ value, disabled }) => {
|
||||
const isUserCountry = value === committedValues.country;
|
||||
return !disabled || isUserCountry;
|
||||
});
|
||||
};
|
||||
|
||||
handleEditableFieldChange = (name, value) => {
|
||||
@@ -179,7 +174,7 @@ class AccountSettingsPage extends React.Component {
|
||||
};
|
||||
|
||||
handleSubmit = (formId, values) => {
|
||||
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
|
||||
if (formId === 'country' && this.isDisabledCountry(values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,9 +220,8 @@ class AccountSettingsPage extends React.Component {
|
||||
};
|
||||
|
||||
isDisabledCountry = (country) => {
|
||||
const { countriesCodesList } = this.props;
|
||||
|
||||
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
|
||||
const { disabledCountries } = this.props;
|
||||
return disabledCountries.includes(country);
|
||||
};
|
||||
|
||||
isEditable(fieldName) {
|
||||
@@ -733,10 +727,8 @@ class AccountSettingsPage extends React.Component {
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
|
||||
<AdditionalProfileFieldsSlot />
|
||||
</div>
|
||||
<div className="account-section pt-3 mb-6" id="social-media">
|
||||
<div className="account-section pt-3" id="social-media">
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||
</h2>
|
||||
@@ -772,10 +764,9 @@ class AccountSettingsPage extends React.Component {
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-light-700" />
|
||||
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<div className="border border-light-700 my-6" />
|
||||
<NotificationSettings />
|
||||
<div className="border border-light-700 my-6" />
|
||||
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||
@@ -818,15 +809,16 @@ class AccountSettingsPage extends React.Component {
|
||||
<ThirdPartyAuth />
|
||||
</div>
|
||||
|
||||
{getConfig().ENABLE_ACCOUNT_DELETION && (
|
||||
{getConfig().ENABLE_ACCOUNT_DELETION
|
||||
&& (
|
||||
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
|
||||
<DeleteAccount
|
||||
isVerifiedAccount={this.props.isActive}
|
||||
hasLinkedTPA={hasLinkedTPA}
|
||||
canDeleteAccount={this.canDeleteAccount()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -891,15 +883,12 @@ AccountSettingsPage.propTypes = {
|
||||
name: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
secondary_email: PropTypes.string,
|
||||
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
secondary_email_enabled: PropTypes.bool,
|
||||
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
country: PropTypes.string,
|
||||
level_of_education: PropTypes.string,
|
||||
gender: PropTypes.string,
|
||||
extended_profile: PropTypes.arrayOf(PropTypes.shape({
|
||||
field_name: PropTypes.string,
|
||||
field_value: PropTypes.string,
|
||||
})),
|
||||
extended_profile: PropTypes.string,
|
||||
language_proficiencies: PropTypes.string,
|
||||
pending_name_change: PropTypes.string,
|
||||
phone_number: PropTypes.string,
|
||||
@@ -952,12 +941,9 @@ AccountSettingsPage.propTypes = {
|
||||
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||
connected: PropTypes.bool,
|
||||
})),
|
||||
nameChangeModal: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
formId: PropTypes.string,
|
||||
}),
|
||||
PropTypes.bool,
|
||||
]),
|
||||
nameChangeModal: PropTypes.shape({
|
||||
formId: PropTypes.string,
|
||||
}),
|
||||
verifiedName: PropTypes.shape({
|
||||
verified_name: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
@@ -977,12 +963,7 @@ AccountSettingsPage.propTypes = {
|
||||
),
|
||||
navigate: PropTypes.func.isRequired,
|
||||
location: PropTypes.string.isRequired,
|
||||
countriesCodesList: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
}),
|
||||
),
|
||||
disabledCountries: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
AccountSettingsPage.defaultProps = {
|
||||
@@ -1005,11 +986,11 @@ AccountSettingsPage.defaultProps = {
|
||||
tpaProviders: [],
|
||||
isActive: true,
|
||||
secondary_email_enabled: false,
|
||||
nameChangeModal: {} || false,
|
||||
nameChangeModal: {},
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: [],
|
||||
countriesCodesList: [],
|
||||
disabledCountries: [],
|
||||
};
|
||||
|
||||
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
||||
|
||||
@@ -19,14 +19,12 @@ const JumpNav = ({
|
||||
'basic-information',
|
||||
'profile-information',
|
||||
'social-media',
|
||||
'notifications',
|
||||
'site-preferences',
|
||||
'linked-accounts',
|
||||
'delete-account',
|
||||
]}
|
||||
className="list-unstyled"
|
||||
currentClassName="font-weight-bold"
|
||||
offset={-64}
|
||||
>
|
||||
<li>
|
||||
<NavHashLink to="#basic-information">
|
||||
@@ -43,11 +41,6 @@ const JumpNav = ({
|
||||
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavHashLink to="#notifications">
|
||||
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavHashLink to="#site-preferences">
|
||||
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-import-assign */
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -12,11 +13,8 @@ import {
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export const fetchSettingsSuccess = ({
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
}) => ({
|
||||
type: FETCH_SETTINGS.SUCCESS,
|
||||
payload: {
|
||||
@@ -36,7 +35,6 @@ export const fetchSettingsSuccess = ({
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -132,10 +132,6 @@ export function getStatesList(country) {
|
||||
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
||||
}
|
||||
|
||||
export const FIELD_LABELS = {
|
||||
COUNTRY: 'country',
|
||||
};
|
||||
|
||||
export const DECLINED = 'declined';
|
||||
export const SELF_DESCRIBE = 'self-describe';
|
||||
export const OTHER = 'other';
|
||||
|
||||
@@ -39,7 +39,7 @@ export const defaultState = {
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: {},
|
||||
countriesCodesList: [],
|
||||
disabledCountries: ['RU'],
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action = {}) => {
|
||||
@@ -65,7 +65,6 @@ const reducer = (state = defaultState, action = {}) => {
|
||||
loaded: true,
|
||||
loadingError: null,
|
||||
verifiedNameHistory: action.payload.verifiedNameHistory,
|
||||
countriesCodesList: action.payload.countriesCodesList,
|
||||
};
|
||||
case FETCH_SETTINGS.FAILURE:
|
||||
return {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function* handleFetchSettings() {
|
||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
|
||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
||||
} = yield call(
|
||||
getSettings,
|
||||
username,
|
||||
@@ -71,7 +71,6 @@ export function* handleFetchSettings() {
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList: countries,
|
||||
}));
|
||||
} catch (e) {
|
||||
yield put(fetchSettingsFailure(e.message));
|
||||
|
||||
@@ -88,11 +88,6 @@ const previousSiteLanguageSelector = createSelector(
|
||||
accountSettings => accountSettings.previousSiteLanguage,
|
||||
);
|
||||
|
||||
const countriesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.countriesCodesList,
|
||||
);
|
||||
|
||||
const editableFieldErrorSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
accountSettingsSelector,
|
||||
@@ -211,6 +206,11 @@ const activeAccountSelector = createSelector(
|
||||
accountSettings => accountSettings.values.is_active,
|
||||
);
|
||||
|
||||
const disabledCountriesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.disabledCountries,
|
||||
);
|
||||
|
||||
export const siteLanguageSelector = createSelector(
|
||||
previousSiteLanguageSelector,
|
||||
draftsSelector,
|
||||
@@ -242,7 +242,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
mostRecentApprovedVerifiedNameValueSelector,
|
||||
mostRecentVerifiedNameSelector,
|
||||
sortedVerifiedNameHistorySelector,
|
||||
countriesSelector,
|
||||
disabledCountriesSelector,
|
||||
(
|
||||
accountSettings,
|
||||
siteLanguageOptions,
|
||||
@@ -260,7 +260,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
disabledCountries,
|
||||
) => ({
|
||||
siteLanguageOptions,
|
||||
siteLanguage,
|
||||
@@ -281,7 +281,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
disabledCountries,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import pick from 'lodash.pick';
|
||||
import omit from 'lodash.omit';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
@@ -8,7 +7,6 @@ import isEmpty from 'lodash.isempty';
|
||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
||||
import { FIELD_LABELS } from './constants';
|
||||
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{ id: 'twitter', key: 'social_link_twitter' },
|
||||
@@ -188,24 +186,6 @@ export async function postVerifiedName(data) {
|
||||
.catch(error => handleRequestError(error));
|
||||
}
|
||||
|
||||
function extractCountryList(data) {
|
||||
return data?.fields
|
||||
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
|
||||
?.options?.map(({ value }) => (value)) || [];
|
||||
}
|
||||
|
||||
export async function getCountryList() {
|
||||
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return extractCountryList(data);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
|
||||
* ThirdPartyAuth.
|
||||
@@ -217,14 +197,12 @@ export async function getSettings(username, userRoles) {
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
countries,
|
||||
] = await Promise.all([
|
||||
getAccount(username),
|
||||
getPreferences(username),
|
||||
getThirdPartyAuthProviders(),
|
||||
getProfileDataManager(username, userRoles),
|
||||
getTimeZones(),
|
||||
getCountryList(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -233,7 +211,6 @@ export async function getSettings(username, userRoles) {
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
countries,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first
|
||||
|
||||
|
||||
@@ -76,74 +76,67 @@ export class DeleteAccount extends React.Component {
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
||||
</h2>
|
||||
{
|
||||
this.props.canDeleteAccount ? (
|
||||
<>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.1'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages[deleteAccountText2MessageKey],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<PrintingInstructions />
|
||||
</p>
|
||||
<p className="text-danger h6">
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.warning'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<p>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
||||
</Button>
|
||||
</p>
|
||||
{isVerifiedAccount ? null : (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId={optInInstructionMessageId}
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
/>
|
||||
)}
|
||||
{hasLinkedTPA ? (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId="account.settings.delete.account.please.unlink"
|
||||
supportArticleUrl={supportArticleUrl}
|
||||
/>
|
||||
) : null}
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.1'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages[deleteAccountText2MessageKey],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<PrintingInstructions />
|
||||
</p>
|
||||
<p className="text-danger h6">
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.warning'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<p>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<ConnectedConfirmationModal
|
||||
status={status}
|
||||
errorType={errorType}
|
||||
onSubmit={this.handleSubmit}
|
||||
onCancel={this.handleCancel}
|
||||
onChange={this.handlePasswordChange}
|
||||
password={this.state.password}
|
||||
/>
|
||||
{isVerifiedAccount ? null : (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId={optInInstructionMessageId}
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
||||
</>
|
||||
) : (
|
||||
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
|
||||
)
|
||||
}
|
||||
{hasLinkedTPA ? (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId="account.settings.delete.account.please.unlink"
|
||||
supportArticleUrl={supportArticleUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConnectedConfirmationModal
|
||||
status={status}
|
||||
errorType={errorType}
|
||||
onSubmit={this.handleSubmit}
|
||||
onCancel={this.handleCancel}
|
||||
onChange={this.handlePasswordChange}
|
||||
password={this.state.password}
|
||||
/>
|
||||
|
||||
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +152,6 @@ DeleteAccount.propTypes = {
|
||||
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
||||
hasLinkedTPA: PropTypes.bool,
|
||||
isVerifiedAccount: PropTypes.bool,
|
||||
canDeleteAccount: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -168,7 +160,6 @@ DeleteAccount.defaultProps = {
|
||||
isVerifiedAccount: true,
|
||||
status: null,
|
||||
errorType: null,
|
||||
canDeleteAccount: true,
|
||||
};
|
||||
|
||||
// Assume we're part of the accountSettings state.
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { SuccessModal } from './SuccessModal';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import { SuccessModal } from './SuccessModal'; // eslint-disable-line import/first
|
||||
|
||||
const IntlSuccessModal = injectIntl(SuccessModal);
|
||||
|
||||
@@ -22,40 +20,39 @@ describe('SuccessModal', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should match default closed success modal snapshot', async () => {
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
it('should match default closed success modal snapshot', () => {
|
||||
let tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match open success modal snapshot', async () => {
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create(
|
||||
it('should match open success modal snapshot', () => {
|
||||
const tree = renderer
|
||||
.create((
|
||||
<IntlProvider locale="en">
|
||||
<IntlSuccessModal
|
||||
{...props}
|
||||
status="deleted"
|
||||
status="deleted" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
|
||||
/>
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
</IntlProvider>
|
||||
))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ exports[`BeforeProceedingBanner should match the snapshot when SUPPORT_URL_TO_UN
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="http://test-support.edx"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
unlink all social media accounts
|
||||
|
||||
@@ -41,7 +41,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
|
||||
/>
|
||||
<div
|
||||
aria-label="Are you sure?"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
@@ -242,7 +242,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
||||
/>
|
||||
<div
|
||||
aria-label="Are you sure?"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -28,6 +28,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -74,6 +75,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -116,6 +118,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
activate your account
|
||||
@@ -154,6 +157,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -196,6 +200,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
unlink all social media accounts
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'account.settings.cannot.delete.account.text': {
|
||||
id: 'account.settings.cannot.delete.account.text',
|
||||
defaultMessage: 'Please note that, for legal and regulatory compliance purposes, account deletion is currently unavailable.',
|
||||
description: 'This text is visible when user is not allowed to delete account',
|
||||
},
|
||||
'account.settings.delete.account.header': {
|
||||
id: 'account.settings.delete.account.header',
|
||||
defaultMessage: 'Delete My Account',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-import-assign */
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -12,11 +13,8 @@ import {
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import NameChange from '../NameChange'; // eslint-disable-line import/first
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform';
|
||||
@@ -37,23 +37,24 @@ describe('JumpNav', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render delete account link', async () => {
|
||||
it('should not render Optional Information or delete account link', () => {
|
||||
setConfig({
|
||||
ENABLE_ACCOUNT_DELETION: false,
|
||||
});
|
||||
|
||||
render(
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<IntlJumpNav {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
</IntlProvider>
|
||||
))
|
||||
.toJSON();
|
||||
|
||||
expect(await screen.queryByText('Delete My Account')).toBeNull();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render delete account link', async () => {
|
||||
it('should render Optional Information and delete account link', () => {
|
||||
setConfig({
|
||||
ENABLE_ACCOUNT_DELETION: true,
|
||||
});
|
||||
@@ -62,14 +63,15 @@ describe('JumpNav', () => {
|
||||
...props,
|
||||
};
|
||||
|
||||
render(
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<IntlJumpNav {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
</IntlProvider>
|
||||
))
|
||||
.toJSON();
|
||||
|
||||
expect(await screen.findByText('Delete My Account')).toBeVisible();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,9 @@ exports[`EditableSelectField renders EditableSelectField correctly with editing
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div />
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
|
||||
184
src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap
Normal file
184
src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap
Normal file
@@ -0,0 +1,184 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`JumpNav should not render Optional Information or delete account link 1`] = `
|
||||
<div
|
||||
data-testid="redux-provider"
|
||||
>
|
||||
<div
|
||||
data-testid="browser-router"
|
||||
>
|
||||
<div
|
||||
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
|
||||
>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
style={{}}
|
||||
>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#basic-information"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Account Information
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#profile-information"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Profile Information
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#social-media"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Social Media Links
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#site-preferences"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Site Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#linked-accounts"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Linked Accounts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`JumpNav should render Optional Information and delete account link 1`] = `
|
||||
<div
|
||||
data-testid="redux-provider"
|
||||
>
|
||||
<div
|
||||
data-testid="browser-router"
|
||||
>
|
||||
<div
|
||||
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
|
||||
>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
style={{}}
|
||||
>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#basic-information"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Account Information
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#profile-information"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Profile Information
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#social-media"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Social Media Links
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#site-preferences"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Site Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#linked-accounts"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Linked Accounts
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
<a
|
||||
aria-current="page"
|
||||
className="active"
|
||||
href="/#delete-account"
|
||||
isActive={[Function]}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Delete My Account
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
render, act, screen, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import IdVerificationPageSlot from '../../plugin-slots/IdVerificationPageSlot';
|
||||
import IdVerificationPage from '../IdVerificationPage';
|
||||
import * as selectors from '../data/selectors';
|
||||
|
||||
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
|
||||
@@ -47,7 +47,7 @@ jest.mock('../panels/SubmittedPanel', () => function SubmittedPanelMock() {
|
||||
return <></>;
|
||||
});
|
||||
|
||||
const IntlIdVerificationPage = injectIntl(IdVerificationPageSlot);
|
||||
const IntlIdVerificationPage = injectIntl(IdVerificationPage);
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('IdVerificationPage', () => {
|
||||
|
||||
@@ -6,13 +6,12 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import React, { StrictMode } from 'react';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Route, Routes, Outlet } from 'react-router-dom';
|
||||
|
||||
import Header from '@edx/frontend-component-header';
|
||||
import { FooterSlot } from '@edx/frontend-component-footer';
|
||||
import FooterSlot from '@openedx/frontend-slot-footer';
|
||||
|
||||
import configureStore from './data/configureStore';
|
||||
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
||||
@@ -22,39 +21,37 @@ import messages from './i18n';
|
||||
import './index.scss';
|
||||
import Head from './head/Head';
|
||||
|
||||
const rootNode = createRoot(document.getElementById('root'));
|
||||
subscribe(APP_READY, () => {
|
||||
rootNode.render(
|
||||
<StrictMode>
|
||||
<AppProvider store={configureStore()}>
|
||||
<Head />
|
||||
<Routes>
|
||||
<Route element={(
|
||||
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
|
||||
<Header />
|
||||
<main className="flex-grow-1" id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<FooterSlot />
|
||||
</div>
|
||||
ReactDOM.render(
|
||||
<AppProvider store={configureStore()}>
|
||||
<Head />
|
||||
<Routes>
|
||||
<Route element={(
|
||||
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
|
||||
<Header />
|
||||
<main className="flex-grow-1" id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<FooterSlot />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Route
|
||||
path="/id-verification/*"
|
||||
element={<IdVerificationPageSlot />}
|
||||
/>
|
||||
<Route path="/" element={<AccountSettingsPage />} />
|
||||
<Route path="/notfound" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppProvider>
|
||||
</StrictMode>,
|
||||
>
|
||||
<Route
|
||||
path="/id-verification/*"
|
||||
element={<IdVerificationPageSlot />}
|
||||
/>
|
||||
<Route path="/" element={<AccountSettingsPage />} />
|
||||
<Route path="/notfound" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
});
|
||||
|
||||
subscribe(APP_INIT_ERROR, (error) => {
|
||||
rootNode.render(<ErrorPage message={error.message} />);
|
||||
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
|
||||
});
|
||||
|
||||
initialize({
|
||||
@@ -68,7 +65,6 @@ initialize({
|
||||
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,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
import { EMAIL_CADENCE_PREFERENCES, EMAIL_CADENCE } from './data/constants';
|
||||
import EMAIL_CADENCE from './data/constants';
|
||||
import { selectUpdatePreferencesStatus } from './data/selectors';
|
||||
import { LOADING_STATUS } from '../constants';
|
||||
|
||||
@@ -44,12 +44,12 @@ const EmailCadences = ({
|
||||
className="bg-white shadow d-flex flex-column margin-left-2"
|
||||
data-testid="email-cadence-dropdown"
|
||||
>
|
||||
{Object.values(EMAIL_CADENCE_PREFERENCES).map((cadence) => (
|
||||
{Object.values(EMAIL_CADENCE).map((cadence) => (
|
||||
<Dropdown.Item
|
||||
key={cadence}
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
name={EMAIL_CADENCE}
|
||||
name="email_cadence"
|
||||
className="d-flex justify-content-start py-1.5 font-size-14 cadence-button"
|
||||
size="inline"
|
||||
active={cadence === emailCadence}
|
||||
@@ -71,7 +71,7 @@ const EmailCadences = ({
|
||||
EmailCadences.propTypes = {
|
||||
email: PropTypes.bool.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE_PREFERENCES)).isRequired,
|
||||
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE)).isRequired,
|
||||
notificationType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ import { LOADING_STATUS } from '../constants';
|
||||
import { updatePreferenceToggle } from './data/thunks';
|
||||
import { selectAppPreferences, selectSelectedCourseId, selectUpdatePreferencesStatus } from './data/selectors';
|
||||
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
||||
import {
|
||||
EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES, MIXED,
|
||||
} from './data/constants';
|
||||
|
||||
const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,34 +26,11 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
const NOTIFICATION_CHANNELS = Object.values(notificationChannels());
|
||||
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
||||
|
||||
const getValue = useCallback((notificationChannel, innerText, checked) => {
|
||||
if (notificationChannel === EMAIL_CADENCE && courseId) {
|
||||
return innerText;
|
||||
}
|
||||
return checked;
|
||||
}, [courseId]);
|
||||
|
||||
const getEmailCadence = useCallback((notificationChannel, checked, innerText, emailCadence) => {
|
||||
if (notificationChannel === EMAIL_CADENCE) {
|
||||
return innerText;
|
||||
}
|
||||
if (notificationChannel === EMAIL && checked) {
|
||||
return EMAIL_CADENCE_PREFERENCES.DAILY;
|
||||
}
|
||||
return emailCadence;
|
||||
}, []);
|
||||
|
||||
const onToggle = useCallback((event, notificationType) => {
|
||||
const { name: notificationChannel, checked, innerText } = event.target;
|
||||
const appNotificationPreference = appPreferences.find(preference => preference.id === notificationType);
|
||||
|
||||
const value = getValue(notificationChannel, innerText, checked);
|
||||
const emailCadence = getEmailCadence(
|
||||
notificationChannel,
|
||||
checked,
|
||||
innerText,
|
||||
appNotificationPreference.emailCadence,
|
||||
);
|
||||
const { name: notificationChannel } = event.target;
|
||||
const appNotificationPreference = appPreferences.find(x => x.id === notificationType);
|
||||
const value = notificationChannel === 'email_cadence' && courseId ? event.target.innerText : event.target.checked;
|
||||
const emailCadence = notificationChannel === 'email_cadence' ? event.target.innerText : appNotificationPreference.emailCadence;
|
||||
|
||||
dispatch(updatePreferenceToggle(
|
||||
courseId,
|
||||
@@ -64,9 +38,10 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence !== MIXED ? emailCadence : undefined,
|
||||
emailCadence,
|
||||
));
|
||||
}, [appPreferences, getValue, getEmailCadence, dispatch, courseId, appId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appId, appPreferences]);
|
||||
|
||||
const renderPreference = (preference) => (
|
||||
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
|
||||
@@ -88,7 +63,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
id={`${preference.id}-${channel}`}
|
||||
className="my-1"
|
||||
/>
|
||||
{channel === EMAIL && (
|
||||
{channel === 'email' && (
|
||||
<EmailCadences
|
||||
email={preference.email}
|
||||
onToggle={onToggle}
|
||||
|
||||
@@ -27,14 +27,14 @@ const NotificationSettings = () => {
|
||||
</div>
|
||||
<div className="text-gray-700 font-size-14 mb-3">
|
||||
{intl.formatMessage(messages.notificationCadenceDescription, {
|
||||
dailyTime: '22:00 UTC',
|
||||
weeklyTime: '22:00 UTC Every Sunday',
|
||||
dailyTime: '12:00',
|
||||
weeklyTime: '00:00',
|
||||
})}
|
||||
</div>
|
||||
<div className="mb-5 text-gray-700 font-size-14">
|
||||
{intl.formatMessage(messages.notificationPreferenceGuideBody)}
|
||||
<Hyperlink
|
||||
destination="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/sfd_notifications/index.html"
|
||||
destination="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/sfd_notifications/managing_notifications.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-underline ml-1"
|
||||
@@ -44,7 +44,6 @@ const NotificationSettings = () => {
|
||||
</div>
|
||||
<NotificationCoursesDropdown />
|
||||
<NotificationPreferences courseId={courseId} />
|
||||
<div className="border border-light-700 my-6" />
|
||||
</Container>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
export const EMAIL_CADENCE_PREFERENCES = {
|
||||
const EMAIL_CADENCE = {
|
||||
DAILY: 'Daily',
|
||||
WEEKLY: 'Weekly',
|
||||
};
|
||||
export const EMAIL_CADENCE = 'email_cadence';
|
||||
export const EMAIL = 'email';
|
||||
export const MIXED = 'Mixed';
|
||||
export const RequestStatus = /** @type {const} */ ({
|
||||
IN_PROGRESS: 'in-progress',
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
DENIED: 'denied',
|
||||
PENDING: 'pending',
|
||||
CLEAR: 'clear',
|
||||
PARTIAL: 'partial',
|
||||
PARTIAL_FAILURE: 'partial failure',
|
||||
NOT_FOUND: 'not-found',
|
||||
});
|
||||
|
||||
export default EMAIL_CADENCE;
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { updatePreferenceToggle } from './thunks';
|
||||
import {
|
||||
updatePreferenceValue,
|
||||
fetchNotificationPreferenceSuccess,
|
||||
fetchNotificationPreferenceFailed,
|
||||
} from './actions';
|
||||
import { patchPreferenceToggle, postPreferenceToggle } from './service';
|
||||
import { EMAIL } from './constants';
|
||||
|
||||
jest.mock('./service', () => ({
|
||||
patchPreferenceToggle: jest.fn(),
|
||||
postPreferenceToggle: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./actions', () => ({
|
||||
updatePreferenceValue: jest.fn(),
|
||||
fetchNotificationPreferenceSuccess: jest.fn(),
|
||||
fetchNotificationPreferenceFailed: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('updatePreferenceToggle', () => {
|
||||
const dispatch = jest.fn();
|
||||
const courseId = 'course-v1:edX+DemoX+2023';
|
||||
const notificationApp = 'app';
|
||||
const notificationType = 'type';
|
||||
const notificationChannel = 'channel';
|
||||
const value = true;
|
||||
const emailCadence = 'daily';
|
||||
|
||||
const mockData = {
|
||||
updated_value: false,
|
||||
notification_type: 'ora_grade_assigned',
|
||||
channel: 'email',
|
||||
app: 'grading',
|
||||
successfully_updated_courses: [
|
||||
{
|
||||
course_id: 'course-v1:ACCA+ColSid+1T2024',
|
||||
current_setting: {
|
||||
web: false,
|
||||
push: false,
|
||||
email: false,
|
||||
email_cadence: 'Weekly',
|
||||
},
|
||||
},
|
||||
{
|
||||
course_id: 'course-v1:arbisoft_acca+cs1023+2021_T4',
|
||||
current_setting: {
|
||||
web: false,
|
||||
push: false,
|
||||
email: false,
|
||||
email_cadence: 'Weekly',
|
||||
},
|
||||
},
|
||||
],
|
||||
total_updated: 2,
|
||||
total_courses: 2,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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 });
|
||||
await updatePreferenceToggle(
|
||||
null,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
)(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(updatePreferenceValue(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
!value,
|
||||
));
|
||||
expect(postPreferenceToggle).toHaveBeenCalledWith(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceSuccess(null, { data: mockData }, true));
|
||||
});
|
||||
|
||||
it('should handle email preferences separately', async () => {
|
||||
patchPreferenceToggle.mockResolvedValue({ data: mockData });
|
||||
await updatePreferenceToggle(courseId, notificationApp, notificationType, EMAIL, value, emailCadence)(dispatch);
|
||||
|
||||
expect(patchPreferenceToggle).toHaveBeenCalledWith(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
EMAIL,
|
||||
true,
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceSuccess(courseId, { data: mockData }, false));
|
||||
});
|
||||
|
||||
it('should dispatch fetchNotificationPreferenceFailed on error', async () => {
|
||||
patchPreferenceToggle.mockRejectedValue(new Error('Network Error'));
|
||||
await updatePreferenceToggle(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
)(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(updatePreferenceValue(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
!value,
|
||||
));
|
||||
expect(dispatch).toHaveBeenCalledWith(fetchNotificationPreferenceFailed());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import { EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES } from './constants';
|
||||
import EMAIL_CADENCE from './constants';
|
||||
import {
|
||||
fetchCourseListSuccess,
|
||||
fetchCourseListFetching,
|
||||
@@ -79,8 +79,7 @@ const normalizePreferences = (responseData, courseId) => {
|
||||
push: preferences[appId].notificationTypes[preferenceId].push,
|
||||
email: preferences[appId].notificationTypes[preferenceId].email,
|
||||
info: preferences[appId].notificationTypes[preferenceId].info || '',
|
||||
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence
|
||||
|| EMAIL_CADENCE_PREFERENCES.DAILY,
|
||||
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence || EMAIL_CADENCE.DAILY,
|
||||
coreNotificationTypes: preferences[appId].coreNotificationTypes || [],
|
||||
}
|
||||
));
|
||||
@@ -140,58 +139,32 @@ export const updatePreferenceToggle = (
|
||||
) => (
|
||||
async (dispatch) => {
|
||||
try {
|
||||
// Initially update the UI to give immediate feedback
|
||||
dispatch(updatePreferenceValue(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
!value,
|
||||
));
|
||||
|
||||
// Function to handle data normalization and dispatching success
|
||||
const handleSuccessResponse = (data, isGlobal = false) => {
|
||||
const processedData = courseId
|
||||
? normalizePreferences(camelCaseObject(data), courseId)
|
||||
: camelCaseObject(data);
|
||||
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, processedData, isGlobal));
|
||||
return processedData;
|
||||
};
|
||||
|
||||
// Function to toggle preference based on context (course-specific or global)
|
||||
const togglePreference = async (channel, toggleValue, cadence) => {
|
||||
if (courseId) {
|
||||
return patchPreferenceToggle(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
channel,
|
||||
channel === EMAIL_CADENCE ? cadence : toggleValue,
|
||||
);
|
||||
}
|
||||
|
||||
return postPreferenceToggle(
|
||||
let data = null;
|
||||
if (courseId) {
|
||||
data = await patchPreferenceToggle(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
channel,
|
||||
channel === EMAIL_CADENCE ? undefined : toggleValue,
|
||||
cadence,
|
||||
notificationChannel,
|
||||
value,
|
||||
);
|
||||
};
|
||||
|
||||
// Execute the main preference toggle
|
||||
const data = await togglePreference(notificationChannel, value, emailCadence);
|
||||
handleSuccessResponse(data, !courseId);
|
||||
|
||||
// Handle special case for email notifications
|
||||
if (notificationChannel === EMAIL && value) {
|
||||
const emailCadenceData = await togglePreference(
|
||||
EMAIL_CADENCE,
|
||||
courseId ? undefined : value,
|
||||
EMAIL_CADENCE_PREFERENCES.DAILY,
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
||||
} else {
|
||||
data = await postPreferenceToggle(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
);
|
||||
|
||||
handleSuccessResponse(emailCadenceData, !courseId);
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, camelCaseObject(data), true));
|
||||
}
|
||||
} catch (errors) {
|
||||
dispatch(updatePreferenceValue(
|
||||
|
||||
@@ -27,7 +27,7 @@ const messages = defineMessages({
|
||||
newQuestionPost {New question posts}
|
||||
contentReported {Reported content}
|
||||
courseUpdates {Course updates}
|
||||
oraStaffNotifications {New ORA submission for staff grading}
|
||||
oraStaffNotification {ORA new submissions}
|
||||
oraGradeAssigned {Essay assignment grade received}
|
||||
other {{text}}
|
||||
}`,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Additional Profile Fields
|
||||
|
||||
### Slot ID: `org.openedx.frontend.account.additional_profile_fields.v1`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the additional profile fields in the account page.
|
||||
|
||||
## Example
|
||||
The following `env.config.jsx` will extend the default fields with a additional custom fields through a simple example component.
|
||||
|
||||

|
||||
|
||||
### Using the Example Component
|
||||
Create a file named `env.config.jsx` at the MFE root with this:
|
||||
|
||||
```jsx
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
import Example from './src/plugin-slots/AdditionalProfileFieldsSlot/example';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.account.additional_profile_fields.v1': {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'additional_account_fields',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: Example,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Plugin Props
|
||||
|
||||
When implementing a plugin for this slot, the following props are available:
|
||||
|
||||
### `updateUserProfile`
|
||||
- **Type**: Function
|
||||
- **Description**: A function for updating the user's profile with new field values. This handles the API call to persist changes to the backend.
|
||||
- **Usage**: Pass an object containing the field updates to be saved to the user's profile preserving the required structure. The function automatically handles the persistence and UI updates.
|
||||
|
||||
#### Example
|
||||
``` javascript
|
||||
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||
```
|
||||
|
||||
### `profileFieldValues`
|
||||
- **Type**: Array of Objects
|
||||
- **Description**: Contains the current values of all additional profile fields as an array of objects. Each object has a `fieldName` property (string) and a `fieldValue` property (which can be string, boolean, number, or other data types depending on the field type).
|
||||
- **Usage**: Access specific field values by finding the object with the matching `fieldName` and reading its `fieldValue` property. Use array methods like `find()` to locate specific fields.
|
||||
|
||||
#### Example
|
||||
```json
|
||||
[
|
||||
{
|
||||
"fieldName": "favorite_color",
|
||||
"fieldValue": "red"
|
||||
},
|
||||
{
|
||||
"fieldName": "data_authorization",
|
||||
"fieldValue": true
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### `profileFieldErrors`
|
||||
- **Type**: Object
|
||||
- **Description**: Contains validation errors for profile fields. Each key corresponds to a field name, and the value is the error message.
|
||||
- **Usage**: Check for field-specific errors to display validation feedback to users.
|
||||
|
||||
### `formComponents`
|
||||
- **Type**: Object
|
||||
- **Description**: Provides access to reusable form components that are consistent with the rest of the account page styling and behavior. These components follow the platform's design system and include proper validation and accessibility features.
|
||||
- **Usage**: Use these components in your custom fields implementation to maintain UI consistency. Available components include `SwitchContent` for managing different UI states.
|
||||
|
||||
### `refreshUserProfile`
|
||||
- **Type**: Function
|
||||
- **Description**: A function that triggers a refresh of the user's profile data. This can be used after updating profile fields to ensure the UI reflects the latest data from the server.
|
||||
- **Usage**: Call this function when you need to manually reload the user profile information. Note that `updateUserProfile` typically handles data refresh automatically.
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form, Button } from '@openedx/paragon';
|
||||
|
||||
/**
|
||||
* Straightforward example of how you could use the pluginProps provided by
|
||||
* the AdditionalProfileFieldsSlot to create a custom profile field.
|
||||
*
|
||||
* Here you can set a 'favorite_color' field with radio buttons and
|
||||
* save it to the user's profile, especifically to their `meta` in
|
||||
* the user's model. For more information, see the documentation:
|
||||
*
|
||||
* https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/README.rst#persisting-optional-user-metadata
|
||||
*/
|
||||
const Example = ({
|
||||
updateUserProfile, profileFieldValues, profileFieldErrors, formComponents: { SwitchContent } = {},
|
||||
}) => {
|
||||
const [formMode, setFormMode] = useState('default');
|
||||
|
||||
// Get current favorite color from profileFieldValues
|
||||
const currentColorField = profileFieldValues?.find(field => field.fieldName === 'favorite_color');
|
||||
const currentColor = currentColorField ? currentColorField.fieldValue : '';
|
||||
|
||||
const [value, setValue] = useState(currentColor);
|
||||
const handleChange = e => setValue(e.target.value);
|
||||
|
||||
// Get any validation errors for the favorite_color field
|
||||
const colorFieldError = profileFieldErrors?.favorite_color;
|
||||
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||
setFormMode('default');
|
||||
} catch (error) {
|
||||
setFormMode('edit');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border .border-accent-500 p-3">
|
||||
<h3 className="h3">Example Additional Profile Fields Slot</h3>
|
||||
|
||||
<SwitchContent
|
||||
expression={formMode}
|
||||
cases={{
|
||||
default: (
|
||||
<>
|
||||
<h3 className="text-muted">
|
||||
{value ? `Selected value: ${value}` : 'No color selected'}
|
||||
</h3>
|
||||
<Button onClick={() => setFormMode('edit')}>Edit</Button>
|
||||
</>
|
||||
),
|
||||
edit: (
|
||||
<>
|
||||
<Form.Group>
|
||||
<Form.Label>Which Color?</Form.Label>
|
||||
<Form.RadioSet
|
||||
name="colors"
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
isInvalid={!!colorFieldError}
|
||||
>
|
||||
<Form.Radio value="red">Red</Form.Radio>
|
||||
<Form.Radio value="green">Green</Form.Radio>
|
||||
<Form.Radio value="blue">Blue</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
{colorFieldError && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{colorFieldError}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!value}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Example.propTypes = {
|
||||
updateUserProfile: PropTypes.func.isRequired,
|
||||
profileFieldValues: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
fieldName: PropTypes.string.isRequired,
|
||||
fieldValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
]).isRequired,
|
||||
}),
|
||||
),
|
||||
profileFieldErrors: PropTypes.objectOf(PropTypes.string),
|
||||
formComponents: PropTypes.shape({
|
||||
SwitchContent: PropTypes.elementType.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
export default Example;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
@@ -1,32 +0,0 @@
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
import { fetchSettings, saveSettings } from '../../account-settings/data/actions';
|
||||
|
||||
import SwitchContent from '../../account-settings/SwitchContent';
|
||||
|
||||
const AdditionalProfileFieldsSlot = () => {
|
||||
const dispatch = useDispatch();
|
||||
const extendedProfileValues = useSelector((state) => state.accountSettings.values.extended_profile);
|
||||
const errors = useSelector((state) => state.accountSettings.errors);
|
||||
|
||||
const pluginProps = {
|
||||
refreshUserProfile: (username) => dispatch(fetchSettings(username)),
|
||||
updateUserProfile: (params) => dispatch(saveSettings(null, null, snakeCaseObject(params))),
|
||||
profileFieldValues: camelCaseObject(extendedProfileValues),
|
||||
profileFieldErrors: errors,
|
||||
formComponents: {
|
||||
SwitchContent,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.account.additional_profile_fields.v1"
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdditionalProfileFieldsSlot;
|
||||
@@ -1,15 +1,12 @@
|
||||
# Footer Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.layout.footer.v1`
|
||||
|
||||
### Slot ID Aliases
|
||||
* `footer_slot`
|
||||
### Slot ID: `footer_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the footer.
|
||||
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/tree/master/src/components/footer-slot).
|
||||
|
||||
## Example
|
||||
|
||||
@@ -26,7 +23,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.layout.footer.v1': {
|
||||
footer_slot: {
|
||||
plugins: [
|
||||
{
|
||||
// Hide the default footer
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# ID Verification Page Slot
|
||||
# Footer Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.account.id_verification_page.v1`
|
||||
|
||||
### Slot ID Aliases
|
||||
* `id_verification_page_plugin`
|
||||
### Slot ID: `id_verification_page_plugin`
|
||||
|
||||
## Description
|
||||
|
||||
@@ -22,13 +19,13 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.account.id_verification_page.v1': {
|
||||
id_verification_page_plugin: {
|
||||
plugins: [
|
||||
{
|
||||
// Insert a custom IDV Page
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_id_verification_page',
|
||||
id: 'id_verification_page_plugin',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<div>
|
||||
|
||||
@@ -2,10 +2,7 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import IdVerificationPage from '../../id-verification';
|
||||
|
||||
const IdVerificationPageSlot = () => (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.account.id_verification_page.v1"
|
||||
idAliases={['id_verification_page_plugin']}
|
||||
>
|
||||
<PluginSlot id="id_verification_page_plugin">
|
||||
<IdVerificationPage />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# `frontend-app-account` Plugin Slots
|
||||
|
||||
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
|
||||
* [`org.openedx.frontend.account.id_verification_page.v1`](./IdVerificationPageSlot/)
|
||||
* [`org.openedx.frontend.account.additional_profile_fields.v1`](./AdditionalProfileFieldsSlot/)
|
||||
* [`footer_slot`](./FooterSlot/)
|
||||
* [`id_verification_page_plugin`](./IdVerificationPageSlot/)
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('MockedPluginSlot', () => {
|
||||
it('renders as the slot children directly if there is content within', () => {
|
||||
render(
|
||||
<div role="article">
|
||||
<MockedPluginSlot id="test_plugin">
|
||||
<MockedPluginSlot>
|
||||
<q role="note">How much wood could a woodchuck chuck if a woodchuck could chuck wood?</q>
|
||||
</MockedPluginSlot>
|
||||
</div>,
|
||||
@@ -21,13 +21,9 @@ describe('MockedPluginSlot', () => {
|
||||
|
||||
const component = screen.getByRole('article');
|
||||
expect(component).toBeInTheDocument();
|
||||
const slot = component.querySelector('[data-testid="test_plugin"]');
|
||||
expect(slot).toBeInTheDocument();
|
||||
expect(slot).toHaveTextContent('PluginSlot_test_plugin');
|
||||
// Check if the quote is a direct child of the MockedPluginSlot
|
||||
const quote = slot.querySelector('q');
|
||||
expect(quote).toBeInTheDocument();
|
||||
expect(quote).toHaveTextContent('How much wood could a woodchuck chuck if a woodchuck could chuck wood?');
|
||||
|
||||
// Direct children
|
||||
const quote = component.querySelector(':scope > q');
|
||||
expect(quote.getAttribute('role')).toBe('note');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user