Compare commits

..

15 Commits

Author SHA1 Message Date
sundasnoreen12
42342c2b18 fix: fixed issue to update email cadence for account notification type 2024-11-22 13:26:04 +05:00
sundasnoreen12
5a5de29dea test: added update account preference test case 2024-11-22 01:51:35 +05:00
sundasnoreen12
5381719477 fix: fixed test cases and label 2024-11-20 23:52:19 +05:00
sundasnoreen12
235dd9c462 feat: added api for account notification type 2024-11-20 23:36:14 +05:00
sundasnoreen12
301797aca3 fix: fixed test cases 2024-11-19 16:15:51 +05:00
Awais Ansari
27c686fbe3 feat: added notification preferences settings at account level 2024-11-14 13:40:29 +05:00
ayesha waris
45bcfddab6 chore: rebase with master (#1158)
* fix: fixed support urls (#1155)

* fix(deps): update dependency @edx/frontend-component-header to v5.7.1 (#1156)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.6 (#1157)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix: fixed certificates url

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 14:57:12 +05:00
Awais Ansari
45b0734e83 Merge pull request #1154 from openedx/aansari/rebase-with-master
chore: rebase with master
2024-11-06 12:40:26 +05:00
Awais Ansari
8db7c54119 Merge branch 'master' of github.com:openedx/frontend-app-account into aansari/rebase-with-master 2024-11-05 17:48:34 +05:00
Muhammad Adeel Tajamul
f04fddd8c0 Merge pull request #1146 from openedx/inf-1620-rebase
Rebase 2u-main with master
2024-10-25 06:19:42 +05:00
muhammadadeeltajamul
84de80eb7f chore: rebase 2u-main with master 2024-10-24 07:43:34 +05:00
Alison Langston
7618004054 Merge pull request #1143 from openedx/master
Merge latest changes to 2u-main
2024-10-22 09:18:34 -04:00
Awais Ansari
3b2cf2717e Merge pull request #1135 from openedx/aansari/rebase-with-master
chore: rebase 2u-main with master
2024-10-10 17:03:57 +05:00
Awais Ansari
dbb374860d chore: rebase 2u-main with master 2024-10-10 15:34:25 +05:00
Awais Ansari
aeff548b82 feat: added country disabling feature (#1116)
* feat: added country disabling feature

* refactor: removed isDisabledCountry additional call
2024-10-01 22:11:10 +05:00
45 changed files with 2026 additions and 6437 deletions

3
.env
View File

@@ -15,7 +15,7 @@ LOGO_WHITE_URL=''
SHOW_EMAIL_CHANNEL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV='production'
NODE_ENV=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
@@ -33,4 +33,3 @@ MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK=''
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

@@ -34,4 +34,3 @@ MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

@@ -31,4 +31,3 @@ APP_ID=
MFE_CONFIG_API_URL=
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

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

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}

7388
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/openedx-atlas": "^0.7.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-platform": "8.1.2",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@openedx/frontend-plugin-framework": "^1.2.2",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "22.9.0",
"@tensorflow-models/blazeface": "0.1.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"bowser": "2.11.0",
"classnames": "2.5.1",
"core-js": "3.41.0",
"core-js": "3.38.1",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.5",
"formdata-polyfill": "4.0.10",
@@ -60,12 +60,12 @@
"lodash.pick": "4.4.0",
"lodash.pickby": "4.6.0",
"lodash.snakecase": "4.1.1",
"long": "5.3.2",
"long": "5.2.3",
"memoize-one": "^6.0.0",
"prop-types": "15.8.1",
"qs": "6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"qs": "6.13.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "^6.25.1",
@@ -83,12 +83,12 @@
"universal-cookie": "7.2.2"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.0",
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "^14.3.3",
"@openedx/frontend-build": "14.1.5",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"react-test-renderer": "^18.3.1",
"@testing-library/react": "12.1.5",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.5"
}

View File

@@ -47,7 +47,6 @@ 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';
@@ -67,7 +66,6 @@ class AccountSettingsPage extends React.Component {
'#basic-information': React.createRef(),
'#profile-information': React.createRef(),
'#social-media': React.createRef(),
'#notifications': React.createRef(),
'#site-preferences': React.createRef(),
'#linked-accounts': React.createRef(),
'#delete-account': React.createRef(),
@@ -158,19 +156,17 @@ class AccountSettingsPage extends React.Component {
})),
}));
canDeleteAccount = () => {
const { committedValues } = this.props;
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
};
removeDisabledCountries = (countryList) => {
const { countriesCodesList, committedValues } = this.props;
const committedCountry = committedValues?.country;
const { disabledCountries, committedValues } = this.props;
if (!countriesCodesList.length) {
if (!disabledCountries.length) {
return countryList;
}
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
return countryList.filter(({ value, disabled }) => {
const isUserCountry = value === committedValues.country;
return !disabled || isUserCountry;
});
};
handleEditableFieldChange = (name, value) => {
@@ -178,7 +174,7 @@ class AccountSettingsPage extends React.Component {
};
handleSubmit = (formId, values) => {
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
if (formId === 'country' && this.isDisabledCountry(values)) {
return;
}
@@ -224,9 +220,8 @@ class AccountSettingsPage extends React.Component {
};
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
const { disabledCountries } = this.props;
return disabledCountries.includes(country);
};
isEditable(fieldName) {
@@ -733,7 +728,7 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps}
/>
</div>
<div className="account-section pt-3 mb-6" id="social-media">
<div className="account-section pt-3" id="social-media">
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
@@ -769,10 +764,9 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps}
/>
</div>
<div className="border border-light-700" />
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
<NotificationSettings />
</div>
<div className="border border-light-700 my-6" />
<NotificationSettings />
<div className="border border-light-700 my-6" />
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
@@ -815,15 +809,16 @@ class AccountSettingsPage extends React.Component {
<ThirdPartyAuth />
</div>
{getConfig().ENABLE_ACCOUNT_DELETION && (
{getConfig().ENABLE_ACCOUNT_DELETION
&& (
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
canDeleteAccount={this.canDeleteAccount()}
/>
</div>
)}
)}
</>
);
}
@@ -888,15 +883,12 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
secondary_email_enabled: PropTypes.bool,
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,
extended_profile: PropTypes.arrayOf(PropTypes.shape({
field_name: PropTypes.string,
field_value: PropTypes.string,
})),
extended_profile: PropTypes.string,
language_proficiencies: PropTypes.string,
pending_name_change: PropTypes.string,
phone_number: PropTypes.string,
@@ -949,12 +941,9 @@ AccountSettingsPage.propTypes = {
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.oneOfType([
PropTypes.shape({
formId: PropTypes.string,
}),
PropTypes.bool,
]),
nameChangeModal: PropTypes.shape({
formId: PropTypes.string,
}),
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
@@ -974,12 +963,7 @@ AccountSettingsPage.propTypes = {
),
navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
disabledCountries: PropTypes.arrayOf(PropTypes.string),
};
AccountSettingsPage.defaultProps = {
@@ -1002,11 +986,11 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {} || false,
nameChangeModal: {},
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: [],
disabledCountries: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {

View File

@@ -19,14 +19,12 @@ const JumpNav = ({
'basic-information',
'profile-information',
'social-media',
'notifications',
'site-preferences',
'linked-accounts',
'delete-account',
]}
className="list-unstyled"
currentClassName="font-weight-bold"
offset={-64}
>
<li>
<NavHashLink to="#basic-information">
@@ -43,11 +41,6 @@ const JumpNav = ({
{intl.formatMessage(messages['account.settings.section.social.media'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#notifications">
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#site-preferences">
{intl.formatMessage(messages['account.settings.section.site.preferences'])}

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,7 @@ export const defaultState = {
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
countriesCodesList: [],
disabledCountries: ['RU'],
};
const reducer = (state = defaultState, action = {}) => {
@@ -65,7 +65,6 @@ const reducer = (state = defaultState, action = {}) => {
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
countriesCodesList: action.payload.countriesCodesList,
};
case FETCH_SETTINGS.FAILURE:
return {

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,
@@ -211,6 +206,11 @@ const activeAccountSelector = createSelector(
accountSettings => accountSettings.values.is_active,
);
const disabledCountriesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.disabledCountries,
);
export const siteLanguageSelector = createSelector(
previousSiteLanguageSelector,
draftsSelector,
@@ -242,7 +242,7 @@ export const accountSettingsPageSelector = createSelector(
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
countriesSelector,
disabledCountriesSelector,
(
accountSettings,
siteLanguageOptions,
@@ -260,7 +260,7 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
disabledCountries,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -281,7 +281,7 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
disabledCountries,
}),
);

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://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
</Hyperlink>
</p>
<p>
<Button
variant="outline-danger"
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
/>
)}
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</>
) : (
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
)
}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl={supportArticleUrl}
/>
) : null}
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</div>
);
}
@@ -159,7 +152,6 @@ DeleteAccount.propTypes = {
errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool,
canDeleteAccount: PropTypes.bool,
intl: intlShape.isRequired,
};
@@ -168,7 +160,6 @@ DeleteAccount.defaultProps = {
isVerifiedAccount: true,
status: null,
errorType: null,
canDeleteAccount: true,
};
// Assume we're part of the accountSettings state.

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

@@ -41,7 +41,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
role="dialog"
>
<div
@@ -242,7 +242,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
role="dialog"
>
<div

View File

@@ -28,6 +28,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -74,6 +75,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -116,6 +118,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
onClick={[Function]}
target="_self"
>
activate your account
@@ -154,6 +157,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -196,6 +200,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
onClick={[Function]}
target="_self"
>
unlink all social media accounts

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

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

@@ -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';
@@ -22,39 +21,37 @@ import messages from './i18n';
import './index.scss';
import Head from './head/Head';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => {
rootNode.render(
<StrictMode>
<AppProvider store={configureStore()}>
<Head />
<Routes>
<Route element={(
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
<Header />
<main className="flex-grow-1" id="main">
<Outlet />
</main>
<FooterSlot />
</div>
ReactDOM.render(
<AppProvider store={configureStore()}>
<Head />
<Routes>
<Route element={(
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
<Header />
<main className="flex-grow-1" id="main">
<Outlet />
</main>
<FooterSlot />
</div>
)}
>
<Route
path="/id-verification/*"
element={<IdVerificationPageSlot />}
/>
<Route path="/" element={<AccountSettingsPage />} />
<Route path="/notfound" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</AppProvider>
</StrictMode>,
>
<Route
path="/id-verification/*"
element={<IdVerificationPageSlot />}
/>
<Route path="/" element={<AccountSettingsPage />} />
<Route path="/notfound" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</AppProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
rootNode.render(<ErrorPage message={error.message} />);
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
@@ -68,7 +65,6 @@ initialize({
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false',
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'),
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK,

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

@@ -15,9 +15,6 @@ import { LOADING_STATUS } from '../constants';
import { updatePreferenceToggle } from './data/thunks';
import { selectAppPreferences, selectSelectedCourseId, selectUpdatePreferencesStatus } from './data/selectors';
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
import {
EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES, MIXED,
} from './data/constants';
const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
const dispatch = useDispatch();
@@ -29,34 +26,11 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
const NOTIFICATION_CHANNELS = Object.values(notificationChannels());
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
const getValue = useCallback((notificationChannel, innerText, checked) => {
if (notificationChannel === EMAIL_CADENCE && courseId) {
return innerText;
}
return checked;
}, [courseId]);
const getEmailCadence = useCallback((notificationChannel, checked, innerText, emailCadence) => {
if (notificationChannel === EMAIL_CADENCE) {
return innerText;
}
if (notificationChannel === EMAIL && checked) {
return EMAIL_CADENCE_PREFERENCES.DAILY;
}
return emailCadence;
}, []);
const onToggle = useCallback((event, notificationType) => {
const { name: notificationChannel, checked, innerText } = event.target;
const appNotificationPreference = appPreferences.find(preference => preference.id === notificationType);
const value = getValue(notificationChannel, innerText, checked);
const emailCadence = getEmailCadence(
notificationChannel,
checked,
innerText,
appNotificationPreference.emailCadence,
);
const { name: notificationChannel } = event.target;
const appNotificationPreference = appPreferences.find(x => x.id === notificationType);
const value = notificationChannel === 'email_cadence' && courseId ? event.target.innerText : event.target.checked;
const emailCadence = notificationChannel === 'email_cadence' ? event.target.innerText : appNotificationPreference.emailCadence;
dispatch(updatePreferenceToggle(
courseId,
@@ -64,9 +38,10 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
notificationType,
notificationChannel,
value,
emailCadence !== MIXED ? emailCadence : undefined,
emailCadence,
));
}, [appPreferences, getValue, getEmailCadence, dispatch, courseId, appId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appId, appPreferences]);
const renderPreference = (preference) => (
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
@@ -88,7 +63,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
id={`${preference.id}-${channel}`}
className="my-1"
/>
{channel === EMAIL && (
{channel === 'email' && (
<EmailCadences
email={preference.email}
onToggle={onToggle}

View File

@@ -27,14 +27,14 @@ const NotificationSettings = () => {
</div>
<div className="text-gray-700 font-size-14 mb-3">
{intl.formatMessage(messages.notificationCadenceDescription, {
dailyTime: '22:00 UTC',
weeklyTime: '22:00 UTC Every Sunday',
dailyTime: '12:00',
weeklyTime: '00:00',
})}
</div>
<div className="mb-5 text-gray-700 font-size-14">
{intl.formatMessage(messages.notificationPreferenceGuideBody)}
<Hyperlink
destination="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/sfd_notifications/index.html"
destination="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/sfd_notifications/managing_notifications.html"
target="_blank"
rel="noopener noreferrer"
className="text-decoration-underline ml-1"
@@ -44,7 +44,6 @@ const NotificationSettings = () => {
</div>
<NotificationCoursesDropdown />
<NotificationPreferences courseId={courseId} />
<div className="border border-light-700 my-6" />
</Container>
)
);

View File

@@ -1,18 +1,6 @@
export const EMAIL_CADENCE_PREFERENCES = {
const EMAIL_CADENCE = {
DAILY: 'Daily',
WEEKLY: 'Weekly',
};
export const EMAIL_CADENCE = 'email_cadence';
export const EMAIL = 'email';
export const MIXED = 'Mixed';
export const RequestStatus = /** @type {const} */ ({
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
PENDING: 'pending',
CLEAR: 'clear',
PARTIAL: 'partial',
PARTIAL_FAILURE: 'partial failure',
NOT_FOUND: 'not-found',
});
export default EMAIL_CADENCE;

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,6 @@
import { camelCaseObject } from '@edx/frontend-platform';
import camelCase from 'lodash.camelcase';
import { EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES } from './constants';
import EMAIL_CADENCE from './constants';
import {
fetchCourseListSuccess,
fetchCourseListFetching,
@@ -79,8 +79,7 @@ const normalizePreferences = (responseData, courseId) => {
push: preferences[appId].notificationTypes[preferenceId].push,
email: preferences[appId].notificationTypes[preferenceId].email,
info: preferences[appId].notificationTypes[preferenceId].info || '',
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence
|| EMAIL_CADENCE_PREFERENCES.DAILY,
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence || EMAIL_CADENCE.DAILY,
coreNotificationTypes: preferences[appId].coreNotificationTypes || [],
}
));
@@ -140,58 +139,32 @@ export const updatePreferenceToggle = (
) => (
async (dispatch) => {
try {
// Initially update the UI to give immediate feedback
dispatch(updatePreferenceValue(
notificationApp,
notificationType,
notificationChannel,
!value,
));
// Function to handle data normalization and dispatching success
const handleSuccessResponse = (data, isGlobal = false) => {
const processedData = courseId
? normalizePreferences(camelCaseObject(data), courseId)
: camelCaseObject(data);
dispatch(fetchNotificationPreferenceSuccess(courseId, processedData, isGlobal));
return processedData;
};
// Function to toggle preference based on context (course-specific or global)
const togglePreference = async (channel, toggleValue, cadence) => {
if (courseId) {
return patchPreferenceToggle(
courseId,
notificationApp,
notificationType,
channel,
channel === EMAIL_CADENCE ? cadence : toggleValue,
);
}
return postPreferenceToggle(
let data = null;
if (courseId) {
data = await patchPreferenceToggle(
courseId,
notificationApp,
notificationType,
channel,
channel === EMAIL_CADENCE ? undefined : toggleValue,
cadence,
notificationChannel,
value,
);
};
// Execute the main preference toggle
const data = await togglePreference(notificationChannel, value, emailCadence);
handleSuccessResponse(data, !courseId);
// Handle special case for email notifications
if (notificationChannel === EMAIL && value) {
const emailCadenceData = await togglePreference(
EMAIL_CADENCE,
courseId ? undefined : value,
EMAIL_CADENCE_PREFERENCES.DAILY,
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} else {
data = await postPreferenceToggle(
notificationApp,
notificationType,
notificationChannel,
value,
emailCadence,
);
handleSuccessResponse(emailCadenceData, !courseId);
dispatch(fetchNotificationPreferenceSuccess(courseId, camelCaseObject(data), true));
}
} catch (errors) {
dispatch(updatePreferenceValue(

View File

@@ -27,9 +27,8 @@ const messages = defineMessages({
newQuestionPost {New question posts}
contentReported {Reported content}
courseUpdates {Course updates}
oraStaffNotifications {New ORA submission for staff grading}
oraStaffNotification {ORA new submissions}
oraGradeAssigned {Essay assignment grade received}
newInstructorAllLearnersPost {New posts from instructors}
other {{text}}
}`,
description: 'Display text for Notification Types',

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,4 +1,4 @@
# `frontend-app-account` Plugin Slots
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
* [`org.openedx.frontend.account.id_verification_page.v1`](./IdVerificationPageSlot/)
* [`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');
});