Compare commits

..

3 Commits

Author SHA1 Message Date
ilee2u
3f6e59c6f3 fix: actually correction for gitignore 2024-09-19 12:45:29 -04:00
ilee2u
7452b9d4f6 fix: fixing gitignore env 2024-09-19 12:44:38 -04:00
ilee2u
0ae18e626c feat: setting to toggle Persona plugin on and off. 2024-09-19 12:42:05 -04:00
69 changed files with 2300 additions and 7124 deletions

6
.env
View File

@@ -1,5 +1,4 @@
ACCESS_TOKEN_COOKIE_NAME=''
ACCOUNT_PROFILE_URL=''
BASE_URL=''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
@@ -15,7 +14,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=''
@@ -32,5 +31,4 @@ APP_ID=
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='[]'
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'

View File

@@ -1,5 +1,4 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
ACCOUNT_PROFILE_URL='http://localhost:1995'
BASE_URL='localhost:1997'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
@@ -28,10 +27,9 @@ ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
SHOW_EMAIL_CHANNEL='true'
SHOW_EMAIL_CHANNEL=''
APP_ID=
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='[]'
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'

View File

@@ -30,5 +30,4 @@ MARKETING_EMAILS_OPT_IN=''
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='[]'
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'

View File

@@ -3,4 +3,3 @@ dist/
node_modules/
__mocks__/
__snapshots__/
src/i18n/messages/

View File

@@ -13,11 +13,12 @@ jobs:
- i18n_extract
- lint
- test
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
node-version: ${{ matrix.node }}
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}

4
.gitignore vendored
View File

@@ -17,4 +17,6 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
src/i18n/messages/
src/i18n/messages/
env.config.js

View File

@@ -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

View File

@@ -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
===================

View File

@@ -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
View 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}

7440
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.3.4",
"@edx/frontend-platform": "8.1.1",
"@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",
"@tensorflow-models/blazeface": "0.1.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"@openedx/frontend-plugin-framework": "^1.2.2",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "22.0.0",
"@tensorflow-models/blazeface": "0.0.7",
"@tensorflow/tfjs-converter": "3.21.0",
"@tensorflow/tfjs-core": "3.21.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",
@@ -80,16 +80,16 @@
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "^5.1.1",
"universal-cookie": "7.2.2"
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.0",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"react-test-renderer": "^18.3.1",
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "14.1.2",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "12.1.5",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.5"
"redux-mock-store": "1.5.4"
}
}

View File

@@ -14,7 +14,7 @@ import {
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Hyperlink, Icon, Alert,
Button, Hyperlink, Icon, Alert,
} from '@openedx/paragon';
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
@@ -47,13 +47,10 @@ 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 +65,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(),
@@ -124,15 +120,7 @@ class AccountSettingsPage extends React.Component {
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(
this.removeDisabledCountries(
getCountryList(locale).map(({ code, name }) => ({
value: code,
label: name,
disabled: this.isDisabledCountry(code),
})),
),
),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
stateOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
@@ -159,30 +147,11 @@ class AccountSettingsPage extends React.Component {
})),
}));
canDeleteAccount = () => {
const { committedValues } = this.props;
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
};
removeDisabledCountries = (countryList) => {
const { countriesCodesList, committedValues } = this.props;
const committedCountry = committedValues?.country;
if (!countriesCodesList.length) {
return countryList;
}
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
};
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
handleSubmit = (formId, values) => {
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
return;
}
const { formValues } = this.props;
let extendedProfileObject = {};
@@ -224,12 +193,6 @@ class AccountSettingsPage extends React.Component {
}
};
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
};
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
@@ -348,9 +311,19 @@ class AccountSettingsPage extends React.Component {
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])}
body={
(
<div className="d-flex flex-row">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
</div>
<>
<div className="d-flex flex-row">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
</div>
<div className="d-flex flex-row-reverse mt-3">
<Button
variant="primary"
href="https://support.edx.org/hc/en-us/articles/360004381594-Why-was-my-ID-verification-denied"
>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.help.link'])}
</Button>{' '}
</div>
</>
)
}
/>
@@ -503,8 +476,7 @@ class AccountSettingsPage extends React.Component {
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
// Show State field only if the country is US (could include Canada later)
const { country } = this.props.formValues;
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
const showState = this.props.formValues.country === COUNTRY_WITH_STATES;
const { verifiedName } = this.props;
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
@@ -733,10 +705,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 mb-5" id="social-media">
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
@@ -772,11 +742,8 @@ 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="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<div className="account-section pt-3 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'])}
</h2>
@@ -818,15 +785,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 +859,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,
@@ -915,7 +880,6 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
country: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
@@ -952,12 +916,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 +938,6 @@ AccountSettingsPage.propTypes = {
),
navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
};
AccountSettingsPage.defaultProps = {
@@ -992,7 +947,6 @@ AccountSettingsPage.defaultProps = {
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
country: '',
},
drafts: {},
formErrors: {},
@@ -1005,11 +959,10 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {} || false,
nameChangeModal: {},
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {

View File

@@ -107,7 +107,6 @@ const EditableSelectField = (props) => {
<option
value={subOption.value}
key={`${subOption.value}-${subOption.label}`}
disabled={subOption?.disabled}
>
{subOption.label}
</option>
@@ -116,7 +115,7 @@ const EditableSelectField = (props) => {
);
}
return (
<option value={option.value} key={`${option.value}-${option.label}`} disabled={option?.disabled}>
<option value={option.value} key={`${option.value}-${option.label}`}>
{option.label}
</option>
);

View File

@@ -1,16 +1,21 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon';
import { OpenInNew } from '@openedx/paragon/icons';
import classNames from 'classnames';
import React from 'react';
import { useSelector } from 'react-redux';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import { Link } from 'react-router-dom';
import messages from './AccountSettingsPage.messages';
import { selectShowPreferences } from '../notification-preferences/data/selectors';
const JumpNav = ({
intl,
}) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
const showPreferences = useSelector(selectShowPreferences());
return (
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
@@ -19,14 +24,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 +46,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'])}
@@ -67,6 +65,21 @@ const JumpNav = ({
</li>
)}
</Scrollspy>
{showPreferences && (
<>
<hr />
<Scrollspy
className="list-unstyled"
>
<li>
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
</Link>
</li>
</Scrollspy>
</>
)}
</div>
);
};

View File

@@ -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

View File

@@ -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,
},
});

View File

@@ -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';

View File

@@ -39,7 +39,6 @@ export const defaultState = {
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
countriesCodesList: [],
};
const reducer = (state = defaultState, action = {}) => {
@@ -65,7 +64,6 @@ const reducer = (state = defaultState, action = {}) => {
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
countriesCodesList: action.payload.countriesCodesList,
};
case FETCH_SETTINGS.FAILURE:
return {

View File

@@ -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));

View File

@@ -88,11 +88,6 @@ const previousSiteLanguageSelector = createSelector(
accountSettings => accountSettings.previousSiteLanguage,
);
const countriesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.countriesCodesList,
);
const editableFieldErrorSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
@@ -242,7 +237,6 @@ export const accountSettingsPageSelector = createSelector(
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
countriesSelector,
(
accountSettings,
siteLanguageOptions,
@@ -260,7 +254,6 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -281,7 +274,6 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
}),
);

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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

View File

@@ -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://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
{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.

View File

@@ -11,7 +11,7 @@ const PrintingInstructions = (props) => {
// TODO: What would a generic version of this link look like? Should
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
// We've removed the link from the default message.
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
>
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink>

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -27,7 +27,8 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -73,7 +74,8 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -115,7 +117,8 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
onClick={[Function]}
target="_self"
>
activate your account
@@ -153,7 +156,8 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -195,7 +199,8 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
target="_self"
>
unlink all social media accounts

View File

@@ -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',

View File

@@ -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

View File

@@ -23,15 +23,10 @@ export async function patchPreferences(username, params) {
export async function postSetLang(code) {
const formData = new FormData();
const requestConfig = {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
};
const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`;
formData.append('language', code);
await getAuthenticatedHttpClient()
.post(url, formData, requestConfig);
.post(`${getConfig().LMS_BASE_URL}/i18n/setlang/`, formData, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
}

View File

@@ -71,16 +71,6 @@ describe('AccountSettingsPage', () => {
afterEach(() => jest.clearAllMocks());
beforeAll(() => {
global.lightningjs = {
require: jest.fn().mockImplementation((module, url) => ({ moduleName: module, url })),
};
});
afterAll(() => {
delete global.lightningjs;
});
it('renders AccountSettingsPage correctly with editing enabled', async () => {
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));

View File

@@ -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();
});
});

View File

@@ -172,7 +172,9 @@ exports[`EditableSelectField renders EditableSelectField correctly with editing
/>
</svg>
</span>
<div />
<div>
</div>
</div>
</div>
<p>

View 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>
`;

View File

@@ -84,7 +84,7 @@ const mockData = {
profileDataManager: null,
},
notificationPreferences: {
showPreferences: true,
showPreferences: false,
courses: {
status: 'success',
courses: [],
@@ -98,7 +98,7 @@ const mockData = {
preferences: {
status: 'idle',
updatePreferenceStatus: 'idle',
selectedCourse: 'account',
selectedCourse: null,
preferences: [],
apps: [],
nonEditable: {},

View File

@@ -16,9 +16,8 @@ export async function getThirdPartyAuthProviders() {
}
export async function postDisconnectAuth(url) {
const requestConfig = { headers: { Accept: 'application/json' } };
const { data } = await getAuthenticatedHttpClient()
.post(url, {}, requestConfig)
.post(url)
.catch(handleRequestError);
return data;
}

View File

@@ -1,16 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
const Divider = ({ className, ...props }) => (
<div className={classNames('divider', className)} {...props} />
);
Divider.propTypes = {
className: PropTypes.string,
};
Divider.defaultProps = {
className: undefined,
};
export default Divider;

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as Divider } from './Divider';

View File

@@ -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', () => {

View File

@@ -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';
@@ -21,40 +20,42 @@ import messages from './i18n';
import './index.scss';
import Head from './head/Head';
import NotificationCourses from './notification-preferences/NotificationCourses';
import NotificationPreferences from './notification-preferences/NotificationPreferences';
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="/notifications/:courseId" element={<NotificationPreferences />} />
<Route path="/notifications" element={<NotificationCourses />} />
<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 +69,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,

View File

@@ -118,7 +118,7 @@ $fa-font-path: "~font-awesome/fonts";
}
.dropdown-item:active,
.dropdown-item:focus,
.dropdown-item:focus,
.btn-tertiary:not(:disabled):not(.disabled).active {
background-color: $light-300 !important;
}
@@ -131,20 +131,6 @@ $fa-font-path: "~font-awesome/fonts";
.h-4\.5 {
height: 36px;
}
.course-dropdown{
#course-dropdown-btn {
width: 100%;
font-size: 14px !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
border: 1px solid $light-500 !important;
}
.dropdown-item {
font-size: 14px !important;
}
}
}
.usabilla_live_button_container {

View File

@@ -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,
};

View File

@@ -0,0 +1,85 @@
import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { ArrowForwardIos } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Container, Icon, Spinner,
} from '@openedx/paragon';
import messages from './messages';
import { useFeedbackWrapper } from '../hooks';
import { fetchCourseList } from './data/thunks';
import { NotFoundPage } from '../account-settings';
import { IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants';
import { selectCourseList, selectCourseListStatus, selectPagination } from './data/selectors';
const NotificationCourses = ({ intl }) => {
useFeedbackWrapper();
const dispatch = useDispatch();
const coursesList = useSelector(selectCourseList());
const courseListStatus = useSelector(selectCourseListStatus());
const { hasMore, currentPage } = useSelector(selectPagination());
const loadMore = useCallback((page = 1, pageSize = 10) => {
dispatch(fetchCourseList(page, pageSize));
}, [dispatch]);
useEffect(() => {
if (courseListStatus === IDLE_STATUS) { loadMore(); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (courseListStatus === SUCCESS_STATUS && coursesList.length === 0) {
return <NotFoundPage />;
}
return (
<Container size="md">
<h2 className="notification-heading mt-6 mb-5.5">
{intl.formatMessage(messages.notificationHeading)}
</h2>
<div data-testid="courses-list">
{coursesList.map(course => (
<Link
key={course.id}
to={`/notifications/${course.id}`}
className="text-decoration-none"
>
<div className="mb-4 d-flex text-gray-700">
<span className="ml-0 mr-auto">
{course.name}
</span>
<span className="ml-auto mr-0">
<Icon src={ArrowForwardIos} />
</span>
</div>
</Link>
))}
</div>
{courseListStatus === LOADING_STATUS ? (
<div className="d-flex">
<Spinner
variant="primary"
animation="border"
className="mx-auto my-auto"
size="lg"
data-testid="loading-spinner"
/>
</div>
) : hasMore && (
<Button variant="primary" className="w-100 bg-primary-500" onClick={() => loadMore(currentPage + 1)}>
{intl.formatMessage(messages.loadMoreCourses)}
</Button>
)}
</Container>
);
};
NotificationCourses.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(NotificationCourses);

View File

@@ -0,0 +1,97 @@
/* eslint-disable no-import-assign */
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { BrowserRouter as Router } from 'react-router-dom';
import * as auth from '@edx/frontend-platform/auth';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { defaultState } from './data/reducers';
import NotificationCourses from './NotificationCourses';
import { LOADING_STATUS, SUCCESS_STATUS } from '../constants';
const mockStore = configureStore();
jest.mock('@edx/frontend-platform/auth');
const courseList = [
{ id: 'course-id-1', name: 'Course Name 1' },
{ id: 'course-id-2', name: 'Course Name 2' },
{ id: 'course-id-3', name: 'Course Name 3' },
];
const setupStore = (override = {}) => {
const storeState = defaultState;
storeState.courses = {
...storeState.courses,
...override,
};
const store = mockStore({
notificationPreferences: storeState,
});
return store;
};
const renderComponent = (store = {}) => (
render(
<Router>
<IntlProvider locale="en">
<Provider store={store}>
<NotificationCourses />
</Provider>
</IntlProvider>
</Router>,
)
);
describe('Notification Courses', () => {
let store;
beforeEach(() => {
store = setupStore({
courses: courseList,
status: SUCCESS_STATUS,
pagination: {
count: 3,
currentPage: 1,
hasMore: false,
totalPages: 1,
},
});
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
window.lightningjs = null;
});
afterEach(() => jest.clearAllMocks());
it('tests if all courses are available', async () => {
await renderComponent(store);
expect(screen.queryByTestId('courses-list').children).toHaveLength(3);
});
it('show spinner if api call is in progress', async () => {
store = setupStore({ status: LOADING_STATUS });
await renderComponent(store);
expect(screen.queryByTestId('loading-spinner')).toBeInTheDocument();
});
it('show not found page if course list is empty', async () => {
store = setupStore({ status: SUCCESS_STATUS, courses: [] });
await renderComponent(store);
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
});
it('show load more courses button when hasMore True', async () => {
store = setupStore({ status: SUCCESS_STATUS, pagination: { ...store.pagination, hasMore: true, totalPages: 2 } });
await renderComponent(store);
expect(screen.queryByText('Load more courses')).toBeInTheDocument();
});
});

View File

@@ -1,73 +0,0 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon';
import { IDLE_STATUS, SUCCESS_STATUS } from '../constants';
import { selectCourseList, selectCourseListStatus, selectSelectedCourseId } from './data/selectors';
import { fetchCourseList, setSelectedCourse } from './data/thunks';
import messages from './messages';
const NotificationCoursesDropdown = () => {
const intl = useIntl();
const dispatch = useDispatch();
const coursesList = useSelector(selectCourseList());
const courseListStatus = useSelector(selectCourseListStatus());
const selectedCourseId = useSelector(selectSelectedCourseId());
const selectedCourse = useMemo(
() => coursesList.find((course) => course.id === selectedCourseId),
[coursesList, selectedCourseId],
);
const handleCourseSelection = useCallback((courseId) => {
dispatch(setSelectedCourse(courseId));
}, [dispatch]);
const fetchCourses = useCallback((page = 1, pageSize = 99999) => {
dispatch(fetchCourseList(page, pageSize));
}, [dispatch]);
useEffect(() => {
if (courseListStatus === IDLE_STATUS) {
fetchCourses();
}
}, [courseListStatus, fetchCourses]);
return (
courseListStatus === SUCCESS_STATUS && (
<div className="mb-5">
<h5 className="text-primary-500 mb-3">{intl.formatMessage(messages.notificationDropdownlabel)}</h5>
<Dropdown className="course-dropdown">
<Dropdown.Toggle
variant="outline-primary"
id="course-dropdown-btn"
className="w-100 justify-content-between small"
>
{selectedCourse?.name}
</Dropdown.Toggle>
<Dropdown.Menu className="w-100">
{coursesList.map((course) => (
<Dropdown.Item
className="w-100"
key={course.id}
active={course.id === selectedCourse?.id}
eventKey={course.id}
onSelect={handleCourseSelection}
>
{course.name}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
<span className="x-small text-gray-500">
{selectedCourse?.name === 'Account'
? intl.formatMessage(messages.notificationDropdownApplies)
: intl.formatMessage(messages.notificationCourseDropdownApplies)}
</span>
</div>
)
);
};
export default NotificationCoursesDropdown;

View File

@@ -11,22 +11,27 @@ import { useIsOnMobile } from '../hooks';
import NotificationTypes from './NotificationTypes';
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
import NotificationPreferenceColumn from './NotificationPreferenceColumn';
import { selectPreferenceAppToggleValue, selectAppPreferences } from './data/selectors';
import { selectPreferenceAppToggleValue, selectSelectedCourseId, selectAppPreferences } from './data/selectors';
const NotificationPreferenceApp = ({ appId }) => {
const intl = useIntl();
const courseId = useSelector(selectSelectedCourseId());
const appToggle = useSelector(selectPreferenceAppToggleValue(appId));
const appPreferences = useSelector(selectAppPreferences(appId));
const mobileView = useIsOnMobile();
const NOTIFICATION_CHANNELS = notificationChannels();
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
if (!courseId) {
return null;
}
return (
!hideAppPreferences && (
<Collapsible.Advanced
open={appToggle}
data-testid={`${appId}-app`}
className={classNames({ 'mb-4.5': !mobileView && appToggle })}
className={classNames({ 'mb-5': !mobileView && appToggle })}
>
<Collapsible.Trigger>
<div className="d-flex align-items-center">

View File

@@ -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,9 @@ 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 value = notificationChannel === 'email_cadence' ? event.target.innerText : event.target.checked;
dispatch(updatePreferenceToggle(
courseId,
@@ -64,9 +36,9 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
notificationType,
notificationChannel,
value,
emailCadence !== MIXED ? emailCadence : undefined,
));
}, [appPreferences, getValue, getEmailCadence, dispatch, courseId, appId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appId]);
const renderPreference = (preference) => (
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
@@ -88,7 +60,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
id={`${preference.id}-${channel}`}
className="my-1"
/>
{channel === EMAIL && (
{channel === 'email' && (
<EmailCadences
email={preference.email}
onToggle={onToggle}

View File

@@ -1,26 +1,35 @@
import React, { useEffect, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import { ArrowBack } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Spinner, NavItem } from '@openedx/paragon';
import {
Container, Hyperlink, Icon, Spinner, NavItem,
} from '@openedx/paragon';
import { useIsOnMobile } from '../hooks';
import messages from './messages';
import { NotFoundPage } from '../account-settings';
import NotificationPreferenceApp from './NotificationPreferenceApp';
import { fetchCourseNotificationPreferences } from './data/thunks';
import { LOADING_STATUS } from '../constants';
import { fetchCourseList, fetchCourseNotificationPreferences } from './data/thunks';
import {
selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId, selectSelectedCourseId,
FAILURE_STATUS, IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS,
} from '../constants';
import {
selectCourse, selectCourseList, selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId,
} from './data/selectors';
import { notificationChannels } from './data/utils';
const NotificationPreferences = () => {
const { courseId } = useParams();
const dispatch = useDispatch();
const intl = useIntl();
const courseStatus = useSelector(selectCourseListStatus());
const courseId = useSelector(selectSelectedCourseId());
const coursesList = useSelector(selectCourseList());
const course = useSelector(selectCourse(courseId));
const notificationStatus = useSelector(selectNotificationPreferencesStatus());
const preferenceAppsIds = useSelector(selectPreferenceAppsId());
const mobileView = useIsOnMobile();
@@ -34,16 +43,46 @@ const NotificationPreferences = () => {
), [preferenceAppsIds]);
useEffect(() => {
if ([IDLE_STATUS, FAILURE_STATUS].includes(courseStatus)) {
dispatch(fetchCourseList());
}
dispatch(fetchCourseNotificationPreferences(courseId));
}, [courseId, dispatch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
if (preferenceAppsIds.length === 0) {
return null;
if (
(courseStatus === SUCCESS_STATUS && coursesList.length === 0)
|| (notificationStatus === FAILURE_STATUS && coursesList.length !== 0)
) {
return <NotFoundPage />;
}
return (
<div className="h-100">
{!mobileView && !isLoading && (
<Container size="sm" className="notification-preferences">
<h2 className="notification-heading mt-6 mb-4.5">
{intl.formatMessage(messages.notificationHeading)}
</h2>
<div className="mb-6 text-gray-700 font-size-14 margin-bottom-32">
{intl.formatMessage(messages.notificationPreferenceGuideBody)}
<Hyperlink
destination="https://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"
>
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
</Hyperlink>
</div>
<div className="h-100">
<div className="d-flex mb-5">
<Link to="/notifications">
<Icon className="text-primary-500" src={ArrowBack} />
</Link>
<span className="notification-course-title ml-auto mr-auto text-primary-500">
{course?.name}
</span>
</div>
{!mobileView && !isLoading && (
<div className="d-flex flex-row justify-content-between float-right">
<div className="d-flex">
{Object.values(NOTIFICATION_CHANNELS).map((channel) => (
@@ -64,20 +103,21 @@ const NotificationPreferences = () => {
))}
</div>
</div>
)}
{preferencesList}
{isLoading && (
<div className="d-flex">
<Spinner
variant="primary"
animation="border"
className="mx-auto my-auto"
size="lg"
data-testid="loading-spinner"
/>
</div>
)}
</div>
)}
{preferencesList}
{isLoading && (
<div className="d-flex">
<Spinner
variant="primary"
animation="border"
className="mx-auto my-auto"
size="lg"
data-testid="loading-spinner"
/>
</div>
)}
</div>
</Container>
);
};

View File

@@ -9,7 +9,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { defaultState } from './data/reducers';
import NotificationPreferences from './NotificationPreferences';
import { LOADING_STATUS, SUCCESS_STATUS } from '../constants';
import { FAILURE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants';
const courseId = 'selected-course-id';
@@ -77,7 +77,6 @@ const setupStore = (override = {}) => {
storeState.courses = {
status: SUCCESS_STATUS,
courses: [
{ id: '', name: 'Account' },
{ id: 'selected-course-id', name: 'Selected Course' },
],
};
@@ -147,15 +146,9 @@ describe('Notification Preferences', () => {
expect(mockDispatch).toHaveBeenCalled();
});
it('update account preference on click', async () => {
store = setupStore({
...defaultPreferences,
status: SUCCESS_STATUS,
selectedCourse: '',
});
it('show not found page if invalid course id is entered in url', async () => {
store = setupStore({ status: FAILURE_STATUS, selectedCourse: 'invalid-course-id' });
await render(notificationPreferences(store));
const element = screen.getByTestId('core-web');
await fireEvent.click(element);
expect(mockDispatch).toHaveBeenCalled();
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
});
});

View File

@@ -1,53 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container, Hyperlink } from '@openedx/paragon';
import { selectSelectedCourseId, selectShowPreferences } from './data/selectors';
import messages from './messages';
import NotificationCoursesDropdown from './NotificationCoursesDropdown';
import NotificationPreferences from './NotificationPreferences';
import { useFeedbackWrapper } from '../hooks';
const NotificationSettings = () => {
useFeedbackWrapper();
const intl = useIntl();
const showPreferences = useSelector(selectShowPreferences());
const courseId = useSelector(selectSelectedCourseId());
return (
showPreferences && (
<Container className="notification-preferences px-0">
<h2 className="notification-heading mb-3">
{intl.formatMessage(messages.notificationHeading)}
</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">
{intl.formatMessage(messages.notificationCadenceDescription, {
dailyTime: '22:00 UTC',
weeklyTime: '22:00 UTC Every Sunday',
})}
</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"
target="_blank"
rel="noopener noreferrer"
className="text-decoration-underline ml-1"
>
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
</Hyperlink>
</div>
<NotificationCoursesDropdown />
<NotificationPreferences courseId={courseId} />
<div className="border border-light-700 my-6" />
</Container>
)
);
};
export default NotificationSettings;

View File

@@ -10,10 +10,8 @@ export const Actions = {
UPDATE_APP_PREFERENCE: 'updateAppValue',
};
export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => (
dispatch({
type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference,
})
export const fetchNotificationPreferenceSuccess = (courseId, payload) => dispatch => (
dispatch({ type: Actions.FETCHED_PREFERENCES, courseId, payload })
);
export const fetchNotificationPreferenceFetching = () => dispatch => (

View File

@@ -1,18 +1,7 @@
export const EMAIL_CADENCE_PREFERENCES = {
const EMAIL_CADENCE = {
DAILY: 'Daily',
WEEKLY: 'Weekly',
NEVER: 'Never',
};
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;

View File

@@ -5,19 +5,18 @@ import {
SUCCESS_STATUS,
FAILURE_STATUS,
} from '../../constants';
import { normalizeAccountPreferences } from './thunks';
export const defaultState = {
showPreferences: false,
courses: {
status: IDLE_STATUS,
courses: [{ id: '', name: 'Account' }],
courses: [],
pagination: {},
},
preferences: {
status: IDLE_STATUS,
updatePreferenceStatus: IDLE_STATUS,
selectedCourse: '',
selectedCourse: null,
preferences: [],
apps: [],
nonEditable: {},
@@ -67,22 +66,15 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
},
};
case Actions.FETCHED_PREFERENCES:
{
const { preferences } = state;
if (action.isAccountPreference) {
normalizeAccountPreferences(preferences, action.payload);
}
return {
...state,
preferences: {
...preferences,
...state.preferences,
status: SUCCESS_STATUS,
updatePreferenceStatus: SUCCESS_STATUS,
...action.payload,
},
};
}
case Actions.FAILED_PREFERENCES:
return {
...state,

View File

@@ -36,7 +36,9 @@ describe('notification-preferences reducer', () => {
hasMore: false,
totalPages: 1,
},
courseList: [],
courseList: [
{ id: selectedCourseId, name: 'Selected Course' },
],
};
const result = reducer(
state,
@@ -44,7 +46,7 @@ describe('notification-preferences reducer', () => {
);
expect(result.courses).toEqual({
status: SUCCESS_STATUS,
courses: [{ id: '', name: 'Account' }],
courses: data.courseList,
pagination: data.pagination,
});
});
@@ -59,10 +61,7 @@ describe('notification-preferences reducer', () => {
);
expect(result.courses).toEqual({
status,
courses: [{
id: '',
name: 'Account',
}],
courses: [],
pagination: {},
});
});
@@ -83,7 +82,7 @@ describe('notification-preferences reducer', () => {
expect(result.preferences).toEqual({
status: SUCCESS_STATUS,
updatePreferenceStatus: SUCCESS_STATUS,
selectedCourse: '',
selectedCourse: null,
...preferenceData,
});
});
@@ -98,7 +97,7 @@ describe('notification-preferences reducer', () => {
);
expect(result.preferences).toEqual({
status,
selectedCourse: '',
selectedCourse: null,
preferences: [],
apps: [],
nonEditable: {},

View File

@@ -32,22 +32,3 @@ export const patchPreferenceToggle = async (
const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
return data;
};
export const postPreferenceToggle = async (
notificationApp,
notificationType,
notificationChannel,
value,
emailCadence,
) => {
const patchData = snakeCaseObject({
notificationApp,
notificationType: snakeCase(notificationType),
notificationChannel,
value,
emailCadence,
});
const url = `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update-all/`;
const { data } = await getAuthenticatedHttpClient().post(url, patchData);
return data;
};

View File

@@ -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());
});
});

View File

@@ -1,6 +1,5 @@
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,
@@ -15,7 +14,6 @@ import {
getCourseList,
getCourseNotificationPreferences,
patchPreferenceToggle,
postPreferenceToggle,
} from './service';
const normalizeCourses = (responseData) => {
@@ -38,29 +36,8 @@ const normalizeCourses = (responseData) => {
};
};
export const normalizeAccountPreferences = (originalData, updateInfo) => {
const {
app, notificationType, channel, updatedValue,
} = updateInfo.data;
const preferenceToUpdate = originalData.preferences.find(
(preference) => preference.appId === app && preference.id === camelCase(notificationType),
);
if (preferenceToUpdate) {
preferenceToUpdate[camelCase(channel)] = updatedValue;
}
return originalData;
};
const normalizePreferences = (responseData, courseId) => {
let preferences;
if (courseId) {
preferences = responseData.notificationPreferenceConfig;
} else {
preferences = responseData.data;
}
const normalizePreferences = (responseData) => {
const preferences = responseData.notificationPreferenceConfig;
const appKeys = Object.keys(preferences);
const apps = appKeys.map((appId) => ({
@@ -79,8 +56,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 || [],
}
));
@@ -116,7 +92,7 @@ export const fetchCourseNotificationPreferences = (courseId) => (
dispatch(updateSelectedCourse(courseId));
dispatch(fetchNotificationPreferenceFetching());
const data = await getCourseNotificationPreferences(courseId);
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
const normalizedData = normalizePreferences(camelCaseObject(data));
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} catch (errors) {
dispatch(fetchNotificationPreferenceFailed());
@@ -124,75 +100,30 @@ export const fetchCourseNotificationPreferences = (courseId) => (
}
);
export const setSelectedCourse = courseId => (
async (dispatch) => {
dispatch(updateSelectedCourse(courseId));
}
);
export const updatePreferenceToggle = (
courseId,
notificationApp,
notificationType,
notificationChannel,
value,
emailCadence,
) => (
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(
notificationApp,
notificationType,
channel,
channel === EMAIL_CADENCE ? undefined : toggleValue,
cadence,
);
};
// 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,
);
handleSuccessResponse(emailCadenceData, !courseId);
}
const data = await patchPreferenceToggle(
courseId,
notificationApp,
notificationType,
notificationChannel,
value,
);
const normalizedData = normalizePreferences(camelCaseObject(data));
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} catch (errors) {
dispatch(updatePreferenceValue(
notificationApp,

View File

@@ -27,8 +27,7 @@ const messages = defineMessages({
newQuestionPost {New question posts}
contentReported {Reported content}
courseUpdates {Course updates}
oraStaffNotifications {New ORA submission for staff grading}
oraGradeAssigned {Essay assignment grade received}
oraStaffNotification {ORA new submissions}
other {{text}}
}`,
description: 'Display text for Notification Types',
@@ -90,36 +89,6 @@ const messages = defineMessages({
defaultMessage: 'Notifications for certain activities are enabled by default,',
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: {
id: 'notification.cadence.description',
defaultMessage: 'Daily notifications are delivered at {dailyTime}. Weekly notifications are delivered at {weeklyTime}.',
description: 'Notification cadence description',
},
notificationDefaultInfo: {
id: 'notification.default.info',
defaultMessage: 'Notifications for certain activities are enabled by default, as detailed here',
description: 'Default notification info',
},
notificationDropdownlabel: {
id: 'notification.dropdown.label',
defaultMessage: 'Select notifications for',
description: 'Dropdown label',
},
notificationDropdownApplies: {
id: 'notification.dropdown.applies',
defaultMessage: 'Applies to all courses',
description: 'Dropdown applies to all courses',
},
notificationCourseDropdownApplies: {
id: 'notification.dropdown.course.applies',
defaultMessage: 'Overrides account-wide settings',
description: 'Dropdown applies to specific course',
},
});
export default messages;

View File

@@ -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.
![Screenshot of Custom Fields](./images/custom_fields.png)
### 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.

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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/)

View File

@@ -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');
});