Compare commits

...

22 Commits

Author SHA1 Message Date
ayesha waris
2bee6f38d7 refactor: moved hardcoded support urls to constants (#1260)
Co-authored-by: Ayesha Waris <ayesha.waris@192.168.1.31>
2025-06-03 18:01:45 +05:00
Awais Ansari
98e515497d fix: scrolling issue for active menu item 2025-04-15 20:09:42 +05:00
Awais Ansari
168fdb8f8d chore: updated package-lock file 2025-04-15 17:35:45 +05:00
Awais Ansari
41de736e2d test: updated test case snapshots 2025-04-15 17:20:15 +05:00
Hassan Raza
cfc9183dc5 fix: Update course preference title for ora submission (#1222) 2025-04-15 14:29:01 +05:00
sundasnoreen12
58e6521137 test: added test cases 2025-04-15 14:29:01 +05:00
sundasnoreen12
f14fef6b12 refactor: refactor code 2025-04-15 14:29:01 +05:00
sundasnoreen12
72a7d31765 refactor: refactor code 2025-04-15 14:29:01 +05:00
sundasnoreen12
545fabbc0a fix: fixed course level preference issue 2025-04-15 14:29:01 +05:00
sundasnoreen12
b3dbc7499f fix: fixed varaible name 2025-04-15 14:29:01 +05:00
sundasnoreen12
8e2d321bd6 fix: added changes for restricted country 2025-04-15 14:29:01 +05:00
sundasnoreen12
6e995f83eb fix: removed unused selector 2025-04-15 14:29:01 +05:00
sundasnoreen12
517e9f5b7e refactor: refactored code 2025-04-15 14:29:01 +05:00
sundasnoreen12
c611f55b92 fix: changed frequency from never to daily on email preference change 2025-04-15 14:29:01 +05:00
Awais Ansari
5c472198cc fix: updated notifications section url (#1185)
* fix: updated notifiations section url

* fix: updated test cases
2025-04-15 14:28:56 +05:00
Awais Ansari
8b96e6719e feat: added notification preferences settings at account level (#1159)
* feat: added notification preferences settings at account level

* fix: fixed test cases

* feat: added api for account notification type

* fix: fixed test cases and label

* test: added update account preference test case

* fix: fixed issue to update email cadence for account notification type

* refactor: updated time

* fix: fixed mixed cadence issue

* fix: fixed border issue when no preferences

* refactor: refactor code

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
2025-04-15 14:27:59 +05:00
Muhammad Adeel Tajamul
69927f1be1 refactor: moved unable to delete into component (#1177) 2025-04-15 14:23:12 +05:00
Muhammad Adeel Tajamul
5026ac3b3a feat: added feature to hide delete button for countries (#1176) 2025-04-15 14:23:12 +05:00
sundasnoreen12
805b76c7e2 fix: rebase with 2u 2025-04-15 14:22:00 +05:00
Awais Ansari
bd507e2037 fix: translation and console errors (#1166) 2025-04-15 14:16:44 +05:00
ayesha waris
2e7c172fb0 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>
2025-04-15 14:14:16 +05:00
Awais Ansari
aa15df3ab9 feat: added country disabling feature (#1116)
* feat: added country disabling feature

* refactor: removed isDisabledCountry additional call
2025-04-15 14:10:42 +05:00
45 changed files with 4504 additions and 2905 deletions

7
.env
View File

@@ -31,5 +31,10 @@ MARKETING_EMAILS_OPT_IN=''
APP_ID= APP_ID=
MFE_CONFIG_API_URL= MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK='' PASSWORD_RESET_SUPPORT_LINK=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT=''
ACCOUNT_BASICS_SUPPORT_URL=''
EMAIL_CONFIRMATION_SUPPORT_URL=''
CERTIFICATES_SUPPORT_URL=''
LEARNER_SUPPORT_URL=''
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
intl_imports = ./node_modules/.bin/intl-imports.js intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
@@ -19,6 +17,11 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
npm run $(*) npm run $(*)
.PHONY: requirements .PHONY: requirements
precommit:
npm run lint
npm audit
requirements: ## install ci requirements requirements: ## install ci requirements
npm ci npm ci
@@ -38,6 +41,17 @@ detect_changed_source_translations:
# Checking for changed translations... # Checking for changed translations...
git diff --exit-code $(i18n) git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
pull_translations: pull_translations:
rm -rf src/i18n/messages rm -rf src/i18n/messages
mkdir src/i18n/messages mkdir src/i18n/messages

6165
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,9 +47,11 @@ import {
COPPA_COMPLIANCE_YEAR, COPPA_COMPLIANCE_YEAR,
WORK_EXPERIENCE_OPTIONS, WORK_EXPERIENCE_OPTIONS,
getStatesList, getStatesList,
FIELD_LABELS,
} from './data/constants'; } from './data/constants';
import { fetchSiteLanguages } from './site-language'; import { fetchSiteLanguages } from './site-language';
import { fetchCourseList } from '../notification-preferences/data/thunks'; import { fetchCourseList } from '../notification-preferences/data/thunks';
import NotificationSettings from '../notification-preferences/NotificationSettings';
import { withLocation, withNavigate } from './hoc'; import { withLocation, withNavigate } from './hoc';
class AccountSettingsPage extends React.Component { class AccountSettingsPage extends React.Component {
@@ -65,6 +67,7 @@ class AccountSettingsPage extends React.Component {
'#basic-information': React.createRef(), '#basic-information': React.createRef(),
'#profile-information': React.createRef(), '#profile-information': React.createRef(),
'#social-media': React.createRef(), '#social-media': React.createRef(),
'#notifications': React.createRef(),
'#site-preferences': React.createRef(), '#site-preferences': React.createRef(),
'#linked-accounts': React.createRef(), '#linked-accounts': React.createRef(),
'#delete-account': React.createRef(), '#delete-account': React.createRef(),
@@ -120,7 +123,15 @@ class AccountSettingsPage extends React.Component {
countryOptions: [{ countryOptions: [{
value: '', value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']), label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))), }].concat(
this.removeDisabledCountries(
getCountryList(locale).map(({ code, name }) => ({
value: code,
label: name,
disabled: this.isDisabledCountry(code),
})),
),
),
stateOptions: [{ stateOptions: [{
value: '', value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']), label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
@@ -147,11 +158,30 @@ 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) => { handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value); this.props.updateDraft(name, value);
}; };
handleSubmit = (formId, values) => { handleSubmit = (formId, values) => {
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
return;
}
const { formValues } = this.props; const { formValues } = this.props;
let extendedProfileObject = {}; let extendedProfileObject = {};
@@ -193,6 +223,12 @@ class AccountSettingsPage extends React.Component {
} }
}; };
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
};
isEditable(fieldName) { isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName); return !this.props.staticFields.includes(fieldName);
} }
@@ -466,7 +502,8 @@ class AccountSettingsPage extends React.Component {
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country); } = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
// Show State field only if the country is US (could include Canada later) // Show State field only if the country is US (could include Canada later)
const showState = this.props.formValues.country === COUNTRY_WITH_STATES; const { country } = this.props.formValues;
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
const { verifiedName } = this.props; const { verifiedName } = this.props;
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience'); const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
@@ -696,7 +733,7 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps} {...editableFieldProps}
/> />
</div> </div>
<div className="account-section pt-3 mb-5" id="social-media"> <div className="account-section pt-3 mb-6" id="social-media">
<h2 className="section-heading h4 mb-3"> <h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])} {this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2> </h2>
@@ -732,8 +769,11 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps} {...editableFieldProps}
/> />
</div> </div>
<div className="border border-light-700" />
<div className="account-section pt-3 mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}> <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']}>
<h2 className="section-heading h4 mb-3"> <h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])} {this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
</h2> </h2>
@@ -775,16 +815,15 @@ class AccountSettingsPage extends React.Component {
<ThirdPartyAuth /> <ThirdPartyAuth />
</div> </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']}> <div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount <DeleteAccount
isVerifiedAccount={this.props.isActive} isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA} hasLinkedTPA={hasLinkedTPA}
canDeleteAccount={this.canDeleteAccount()}
/> />
</div> </div>
)} )}
</> </>
); );
} }
@@ -849,12 +888,15 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string, name: PropTypes.string,
email: PropTypes.string, email: PropTypes.string,
secondary_email: PropTypes.string, secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.bool, secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
country: PropTypes.string, country: PropTypes.string,
level_of_education: PropTypes.string, level_of_education: PropTypes.string,
gender: PropTypes.string, gender: PropTypes.string,
extended_profile: PropTypes.string, extended_profile: PropTypes.arrayOf(PropTypes.shape({
field_name: PropTypes.string,
field_value: PropTypes.string,
})),
language_proficiencies: PropTypes.string, language_proficiencies: PropTypes.string,
pending_name_change: PropTypes.string, pending_name_change: PropTypes.string,
phone_number: PropTypes.string, phone_number: PropTypes.string,
@@ -870,6 +912,7 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string, name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool, useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string, verified_name: PropTypes.string,
country: PropTypes.string,
}), }),
drafts: PropTypes.shape({}), drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({ formErrors: PropTypes.shape({
@@ -906,9 +949,12 @@ AccountSettingsPage.propTypes = {
tpaProviders: PropTypes.arrayOf(PropTypes.shape({ tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool, connected: PropTypes.bool,
})), })),
nameChangeModal: PropTypes.shape({ nameChangeModal: PropTypes.oneOfType([
formId: PropTypes.string, PropTypes.shape({
}), formId: PropTypes.string,
}),
PropTypes.bool,
]),
verifiedName: PropTypes.shape({ verifiedName: PropTypes.shape({
verified_name: PropTypes.string, verified_name: PropTypes.string,
status: PropTypes.string, status: PropTypes.string,
@@ -928,6 +974,12 @@ AccountSettingsPage.propTypes = {
), ),
navigate: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired, location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
}; };
AccountSettingsPage.defaultProps = { AccountSettingsPage.defaultProps = {
@@ -937,6 +989,7 @@ AccountSettingsPage.defaultProps = {
committedValues: { committedValues: {
useVerifiedNameForCerts: false, useVerifiedNameForCerts: false,
verified_name: null, verified_name: null,
country: '',
}, },
drafts: {}, drafts: {},
formErrors: {}, formErrors: {},
@@ -949,10 +1002,11 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [], tpaProviders: [],
isActive: true, isActive: true,
secondary_email_enabled: false, secondary_email_enabled: false,
nameChangeModal: {}, nameChangeModal: {} || false,
verifiedName: null, verifiedName: null,
mostRecentVerifiedName: {}, mostRecentVerifiedName: {},
verifiedNameHistory: [], verifiedNameHistory: [],
countriesCodesList: [],
}; };
export default withLocation(withNavigate(connect(accountSettingsPageSelector, { export default withLocation(withNavigate(connect(accountSettingsPageSelector, {

View File

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

View File

@@ -1,21 +1,16 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon'; import { breakpoints, useWindowSize } from '@openedx/paragon';
import { OpenInNew } from '@openedx/paragon/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { NavHashLink } from 'react-router-hash-link'; import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy'; import Scrollspy from 'react-scrollspy';
import { Link } from 'react-router-dom';
import messages from './AccountSettingsPage.messages'; import messages from './AccountSettingsPage.messages';
import { selectShowPreferences } from '../notification-preferences/data/selectors';
const JumpNav = ({ const JumpNav = ({
intl, intl,
}) => { }) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth; const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
const showPreferences = useSelector(selectShowPreferences());
return ( return (
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}> <div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
@@ -24,12 +19,14 @@ const JumpNav = ({
'basic-information', 'basic-information',
'profile-information', 'profile-information',
'social-media', 'social-media',
'notifications',
'site-preferences', 'site-preferences',
'linked-accounts', 'linked-accounts',
'delete-account', 'delete-account',
]} ]}
className="list-unstyled" className="list-unstyled"
currentClassName="font-weight-bold" currentClassName="font-weight-bold"
offset={-64}
> >
<li> <li>
<NavHashLink to="#basic-information"> <NavHashLink to="#basic-information">
@@ -46,6 +43,11 @@ const JumpNav = ({
{intl.formatMessage(messages['account.settings.section.social.media'])} {intl.formatMessage(messages['account.settings.section.social.media'])}
</NavHashLink> </NavHashLink>
</li> </li>
<li>
<NavHashLink to="#notifications">
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
</NavHashLink>
</li>
<li> <li>
<NavHashLink to="#site-preferences"> <NavHashLink to="#site-preferences">
{intl.formatMessage(messages['account.settings.section.site.preferences'])} {intl.formatMessage(messages['account.settings.section.site.preferences'])}
@@ -65,21 +67,6 @@ const JumpNav = ({
</li> </li>
)} )}
</Scrollspy> </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> </div>
); );
}; };

View File

@@ -27,6 +27,7 @@ export const fetchSettingsSuccess = ({
profileDataManager, profileDataManager,
timeZones, timeZones,
verifiedNameHistory, verifiedNameHistory,
countriesCodesList,
}) => ({ }) => ({
type: FETCH_SETTINGS.SUCCESS, type: FETCH_SETTINGS.SUCCESS,
payload: { payload: {
@@ -35,6 +36,7 @@ export const fetchSettingsSuccess = ({
profileDataManager, profileDataManager,
timeZones, timeZones,
verifiedNameHistory, verifiedNameHistory,
countriesCodesList,
}, },
}); });

View File

@@ -132,6 +132,10 @@ export function getStatesList(country) {
return country && COUNTRY_STATES_MAP[country.toUpperCase()]; return country && COUNTRY_STATES_MAP[country.toUpperCase()];
} }
export const FIELD_LABELS = {
COUNTRY: 'country',
};
export const DECLINED = 'declined'; export const DECLINED = 'declined';
export const SELF_DESCRIBE = 'self-describe'; export const SELF_DESCRIBE = 'self-describe';
export const OTHER = 'other'; export const OTHER = 'other';

View File

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

View File

@@ -53,7 +53,7 @@ export function* handleFetchSettings() {
const { username, userId, roles: userRoles } = getAuthenticatedUser(); const { username, userId, roles: userRoles } = getAuthenticatedUser();
const { const {
thirdPartyAuthProviders, profileDataManager, timeZones, ...values thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
} = yield call( } = yield call(
getSettings, getSettings,
username, username,
@@ -71,6 +71,7 @@ export function* handleFetchSettings() {
profileDataManager, profileDataManager,
timeZones, timeZones,
verifiedNameHistory, verifiedNameHistory,
countriesCodesList: countries,
})); }));
} catch (e) { } catch (e) {
yield put(fetchSettingsFailure(e.message)); yield put(fetchSettingsFailure(e.message));

View File

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

View File

@@ -1,5 +1,6 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
@@ -7,6 +8,7 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils'; import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth'; import { getThirdPartyAuthProviders } from '../third-party-auth';
import { postVerifiedNameConfig } from '../certificate-preference/data/service'; import { postVerifiedNameConfig } from '../certificate-preference/data/service';
import { FIELD_LABELS } from './constants';
const SOCIAL_PLATFORMS = [ const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' }, { id: 'twitter', key: 'social_link_twitter' },
@@ -186,6 +188,24 @@ export async function postVerifiedName(data) {
.catch(error => handleRequestError(error)); .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 * A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
* ThirdPartyAuth. * ThirdPartyAuth.
@@ -197,12 +217,14 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders, thirdPartyAuthProviders,
profileDataManager, profileDataManager,
timeZones, timeZones,
countries,
] = await Promise.all([ ] = await Promise.all([
getAccount(username), getAccount(username),
getPreferences(username), getPreferences(username),
getThirdPartyAuthProviders(), getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles), getProfileDataManager(username, userRoles),
getTimeZones(), getTimeZones(),
getCountryList(),
]); ]);
return { return {
@@ -211,6 +233,7 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders, thirdPartyAuthProviders,
profileDataManager, profileDataManager,
timeZones, timeZones,
countries,
}; };
} }

View File

@@ -59,7 +59,7 @@ export class DeleteAccount extends React.Component {
hasLinkedTPA, isVerifiedAccount, status, errorType, intl, hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
} = this.props; } = this.props;
const canDelete = isVerifiedAccount && !hasLinkedTPA; const canDelete = isVerifiedAccount && !hasLinkedTPA;
const supportArticleUrl = process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT; const supportArticleUrl = getConfig().SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT;
// TODO: We lack a good way of providing custom language for a particular site. This is a hack // TODO: We lack a good way of providing custom language for a particular site. This is a hack
// to allow edx.org to fulfill its business requirements. // to allow edx.org to fulfill its business requirements.
@@ -76,67 +76,74 @@ export class DeleteAccount extends React.Component {
<h2 className="section-heading h4 mb-3"> <h2 className="section-heading h4 mb-3">
{intl.formatMessage(messages['account.settings.delete.account.header'])} {intl.formatMessage(messages['account.settings.delete.account.header'])}
</h2> </h2>
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p> {
<p> this.props.canDeleteAccount ? (
{intl.formatMessage( <>
messages['account.settings.delete.account.text.1'], <p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
{ siteName: getConfig().SITE_NAME }, <p>
)} {intl.formatMessage(
</p> messages['account.settings.delete.account.text.1'],
<p> { siteName: getConfig().SITE_NAME },
{intl.formatMessage( )}
messages[deleteAccountText2MessageKey], </p>
{ siteName: getConfig().SITE_NAME }, <p>
)} {intl.formatMessage(
</p> messages[deleteAccountText2MessageKey],
<p> { siteName: getConfig().SITE_NAME },
<PrintingInstructions /> )}
</p> </p>
<p className="text-danger h6"> <p>
{intl.formatMessage( <PrintingInstructions />
messages['account.settings.delete.account.text.warning'], </p>
{ siteName: getConfig().SITE_NAME }, <p className="text-danger h6">
)} {intl.formatMessage(
</p> messages['account.settings.delete.account.text.warning'],
<p> { siteName: getConfig().SITE_NAME },
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"> )}
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])} </p>
</Hyperlink> <p>
</p> <Hyperlink destination={getConfig().ACCOUNT_BASICS_SUPPORT_URL}>
<p> {intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
<Button </Hyperlink>
variant="outline-danger" </p>
onClick={canDelete ? this.props.deleteAccountConfirmation : null} <p>
disabled={!canDelete} <Button
> variant="outline-danger"
{intl.formatMessage(messages['account.settings.delete.account.button'])} onClick={canDelete ? this.props.deleteAccountConfirmation : null}
</Button> disabled={!canDelete}
</p> >
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl={getConfig().EMAIL_CONFIRMATION_SUPPORT_URL}
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl={supportArticleUrl}
/>
) : null}
{isVerifiedAccount ? null : ( <ConnectedConfirmationModal
<BeforeProceedingBanner status={status}
instructionMessageId={optInInstructionMessageId} errorType={errorType}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email" onSubmit={this.handleSubmit}
/> onCancel={this.handleCancel}
)} onChange={this.handlePasswordChange}
password={this.state.password}
/>
{hasLinkedTPA ? ( <ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
<BeforeProceedingBanner </>
instructionMessageId="account.settings.delete.account.please.unlink" ) : (
supportArticleUrl={supportArticleUrl} <p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
/> )
) : 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> </div>
); );
} }
@@ -152,6 +159,7 @@ DeleteAccount.propTypes = {
errorType: PropTypes.oneOf(['empty-password', 'server']), errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool, hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool, isVerifiedAccount: PropTypes.bool,
canDeleteAccount: PropTypes.bool,
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };
@@ -160,6 +168,7 @@ DeleteAccount.defaultProps = {
isVerifiedAccount: true, isVerifiedAccount: true,
status: null, status: null,
errorType: null, errorType: null,
canDeleteAccount: true,
}; };
// Assume we're part of the accountSettings state. // 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 // TODO: What would a generic version of this link look like? Should
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime, // CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
// We've removed the link from the default message. // We've removed the link from the default message.
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates" destination={getConfig().CERTIFICATES_SUPPORT_URL}
> >
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])} {props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink> </Hyperlink>

View File

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

View File

@@ -27,7 +27,6 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
<p> <p>
<a <a
className="pgn__hyperlink default-link standalone-link" className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self" target="_self"
> >
Want to change your email, name, or password instead? Want to change your email, name, or password instead?
@@ -73,7 +72,6 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<p> <p>
<a <a
className="pgn__hyperlink default-link standalone-link" className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self" target="_self"
> >
Want to change your email, name, or password instead? Want to change your email, name, or password instead?
@@ -112,15 +110,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
</svg> </svg>
</div> </div>
<div> <div>
Before proceeding, please Before proceeding, please activate your account.
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
target="_self"
>
activate your account
</a>
.
</div> </div>
</div> </div>
</div> </div>
@@ -153,7 +143,6 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<p> <p>
<a <a
className="pgn__hyperlink default-link standalone-link" className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self" target="_self"
> >
Want to change your email, name, or password instead? Want to change your email, name, or password instead?
@@ -192,15 +181,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
</svg> </svg>
</div> </div>
<div> <div>
Before proceeding, please Before proceeding, please unlink all social media accounts.
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
target="_self"
>
unlink all social media accounts
</a>
.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n'; import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({ 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': { 'account.settings.delete.account.header': {
id: 'account.settings.delete.account.header', id: 'account.settings.delete.account.header',
defaultMessage: 'Delete My Account', defaultMessage: 'Delete My Account',

View File

@@ -71,6 +71,16 @@ describe('AccountSettingsPage', () => {
afterEach(() => jest.clearAllMocks()); 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 () => { it('renders AccountSettingsPage correctly with editing enabled', async () => {
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />)); const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));

View File

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

16
src/divider/Divider.jsx Normal file
View File

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

2
src/divider/index.jsx Normal file
View File

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

View File

@@ -130,7 +130,7 @@ const SummaryPanel = (props) => {
`} `}
values={{ values={{
support_link: ( support_link: (
<Alert.Link href="https://support.edx.org/hc/en-us"> <Alert.Link href={getConfig().LEARNER_SUPPORT_URL}>
{props.intl.formatMessage( {props.intl.formatMessage(
messages['id.verification.review.error'], messages['id.verification.review.error'],
{ siteName: getConfig().SITE_NAME }, { siteName: getConfig().SITE_NAME },

View File

@@ -21,8 +21,6 @@ import messages from './i18n';
import './index.scss'; import './index.scss';
import Head from './head/Head'; import Head from './head/Head';
import NotificationCourses from './notification-preferences/NotificationCourses';
import NotificationPreferences from './notification-preferences/NotificationPreferences';
const rootNode = createRoot(document.getElementById('root')); const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => { subscribe(APP_READY, () => {
@@ -39,10 +37,8 @@ subscribe(APP_READY, () => {
</main> </main>
<FooterSlot /> <FooterSlot />
</div> </div>
)} )}
> >
<Route path="/notifications/:courseId" element={<NotificationPreferences />} />
<Route path="/notifications" element={<NotificationCourses />} />
<Route <Route
path="/id-verification/*" path="/id-verification/*"
element={<IdVerificationPageSlot />} element={<IdVerificationPageSlot />}
@@ -72,9 +68,15 @@ initialize({
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false', SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false',
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false), ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'), ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'),
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false), ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false), MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK, PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK,
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT: process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT,
ACCOUNT_BASICS_SUPPORT_URL: process.env.ACCOUNT_BASICS_SUPPORT_URL,
EMAIL_CONFIRMATION_SUPPORT_URL: process.env.EMAIL_CONFIRMATION_SUPPORT_URL,
CERTIFICATES_SUPPORT_URL: process.env.CERTIFICATES_SUPPORT_URL,
LEARNER_SUPPORT_URL: process.env.LEARNER_SUPPORT_URL,
LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL, LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL,
}, 'App loadConfig override handler'); }, 'App loadConfig override handler');
}, },

View File

@@ -118,7 +118,7 @@ $fa-font-path: "~font-awesome/fonts";
} }
.dropdown-item:active, .dropdown-item:active,
.dropdown-item:focus, .dropdown-item:focus,
.btn-tertiary:not(:disabled):not(.disabled).active { .btn-tertiary:not(:disabled):not(.disabled).active {
background-color: $light-300 !important; background-color: $light-300 !important;
} }
@@ -131,6 +131,20 @@ $fa-font-path: "~font-awesome/fonts";
.h-4\.5 { .h-4\.5 {
height: 36px; 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 { .usabilla_live_button_container {

View File

@@ -10,7 +10,7 @@ import {
} from '@openedx/paragon'; } from '@openedx/paragon';
import messages from './messages'; import messages from './messages';
import EMAIL_CADENCE from './data/constants'; import { EMAIL_CADENCE_PREFERENCES, EMAIL_CADENCE } from './data/constants';
import { selectUpdatePreferencesStatus } from './data/selectors'; import { selectUpdatePreferencesStatus } from './data/selectors';
import { LOADING_STATUS } from '../constants'; import { LOADING_STATUS } from '../constants';
@@ -44,12 +44,12 @@ const EmailCadences = ({
className="bg-white shadow d-flex flex-column margin-left-2" className="bg-white shadow d-flex flex-column margin-left-2"
data-testid="email-cadence-dropdown" data-testid="email-cadence-dropdown"
> >
{Object.values(EMAIL_CADENCE).map((cadence) => ( {Object.values(EMAIL_CADENCE_PREFERENCES).map((cadence) => (
<Dropdown.Item <Dropdown.Item
key={cadence} key={cadence}
as={Button} as={Button}
variant="tertiary" variant="tertiary"
name="email_cadence" name={EMAIL_CADENCE}
className="d-flex justify-content-start py-1.5 font-size-14 cadence-button" className="d-flex justify-content-start py-1.5 font-size-14 cadence-button"
size="inline" size="inline"
active={cadence === emailCadence} active={cadence === emailCadence}
@@ -71,7 +71,7 @@ const EmailCadences = ({
EmailCadences.propTypes = { EmailCadences.propTypes = {
email: PropTypes.bool.isRequired, email: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired,
emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE)).isRequired, emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE_PREFERENCES)).isRequired,
notificationType: PropTypes.string.isRequired, notificationType: PropTypes.string.isRequired,
}; };

View File

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

@@ -1,97 +0,0 @@
/* 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
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,8 +10,10 @@ export const Actions = {
UPDATE_APP_PREFERENCE: 'updateAppValue', UPDATE_APP_PREFERENCE: 'updateAppValue',
}; };
export const fetchNotificationPreferenceSuccess = (courseId, payload) => dispatch => ( export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => (
dispatch({ type: Actions.FETCHED_PREFERENCES, courseId, payload }) dispatch({
type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference,
})
); );
export const fetchNotificationPreferenceFetching = () => dispatch => ( export const fetchNotificationPreferenceFetching = () => dispatch => (

View File

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

View File

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

View File

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

View File

@@ -32,3 +32,22 @@ export const patchPreferenceToggle = async (
const { data } = await getAuthenticatedHttpClient().patch(url, patchData); const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
return data; 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

@@ -0,0 +1,150 @@
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,5 +1,6 @@
import { camelCaseObject } from '@edx/frontend-platform'; import { camelCaseObject } from '@edx/frontend-platform';
import EMAIL_CADENCE from './constants'; import camelCase from 'lodash.camelcase';
import { EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES } from './constants';
import { import {
fetchCourseListSuccess, fetchCourseListSuccess,
fetchCourseListFetching, fetchCourseListFetching,
@@ -14,6 +15,7 @@ import {
getCourseList, getCourseList,
getCourseNotificationPreferences, getCourseNotificationPreferences,
patchPreferenceToggle, patchPreferenceToggle,
postPreferenceToggle,
} from './service'; } from './service';
const normalizeCourses = (responseData) => { const normalizeCourses = (responseData) => {
@@ -36,8 +38,29 @@ const normalizeCourses = (responseData) => {
}; };
}; };
const normalizePreferences = (responseData) => { export const normalizeAccountPreferences = (originalData, updateInfo) => {
const preferences = responseData.notificationPreferenceConfig; 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 appKeys = Object.keys(preferences); const appKeys = Object.keys(preferences);
const apps = appKeys.map((appId) => ({ const apps = appKeys.map((appId) => ({
@@ -56,7 +79,8 @@ const normalizePreferences = (responseData) => {
push: preferences[appId].notificationTypes[preferenceId].push, push: preferences[appId].notificationTypes[preferenceId].push,
email: preferences[appId].notificationTypes[preferenceId].email, email: preferences[appId].notificationTypes[preferenceId].email,
info: preferences[appId].notificationTypes[preferenceId].info || '', info: preferences[appId].notificationTypes[preferenceId].info || '',
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence || EMAIL_CADENCE.DAILY, emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence
|| EMAIL_CADENCE_PREFERENCES.DAILY,
coreNotificationTypes: preferences[appId].coreNotificationTypes || [], coreNotificationTypes: preferences[appId].coreNotificationTypes || [],
} }
)); ));
@@ -92,7 +116,7 @@ export const fetchCourseNotificationPreferences = (courseId) => (
dispatch(updateSelectedCourse(courseId)); dispatch(updateSelectedCourse(courseId));
dispatch(fetchNotificationPreferenceFetching()); dispatch(fetchNotificationPreferenceFetching());
const data = await getCourseNotificationPreferences(courseId); const data = await getCourseNotificationPreferences(courseId);
const normalizedData = normalizePreferences(camelCaseObject(data)); const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData)); dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} catch (errors) { } catch (errors) {
dispatch(fetchNotificationPreferenceFailed()); dispatch(fetchNotificationPreferenceFailed());
@@ -100,30 +124,75 @@ export const fetchCourseNotificationPreferences = (courseId) => (
} }
); );
export const setSelectedCourse = courseId => (
async (dispatch) => {
dispatch(updateSelectedCourse(courseId));
}
);
export const updatePreferenceToggle = ( export const updatePreferenceToggle = (
courseId, courseId,
notificationApp, notificationApp,
notificationType, notificationType,
notificationChannel, notificationChannel,
value, value,
emailCadence,
) => ( ) => (
async (dispatch) => { async (dispatch) => {
try { try {
// Initially update the UI to give immediate feedback
dispatch(updatePreferenceValue( dispatch(updatePreferenceValue(
notificationApp, notificationApp,
notificationType, notificationType,
notificationChannel, notificationChannel,
!value, !value,
)); ));
const data = await patchPreferenceToggle(
courseId, // Function to handle data normalization and dispatching success
notificationApp, const handleSuccessResponse = (data, isGlobal = false) => {
notificationType, const processedData = courseId
notificationChannel, ? normalizePreferences(camelCaseObject(data), courseId)
value, : camelCaseObject(data);
);
const normalizedData = normalizePreferences(camelCaseObject(data)); dispatch(fetchNotificationPreferenceSuccess(courseId, processedData, isGlobal));
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData)); 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);
}
} catch (errors) { } catch (errors) {
dispatch(updatePreferenceValue( dispatch(updatePreferenceValue(
notificationApp, notificationApp,

View File

@@ -27,7 +27,7 @@ const messages = defineMessages({
newQuestionPost {New question posts} newQuestionPost {New question posts}
contentReported {Reported content} contentReported {Reported content}
courseUpdates {Course updates} courseUpdates {Course updates}
oraStaffNotification {ORA new submissions} oraStaffNotifications {ORA new submissions}
oraGradeAssigned {Essay assignment grade received} oraGradeAssigned {Essay assignment grade received}
other {{text}} other {{text}}
}`, }`,
@@ -90,6 +90,36 @@ const messages = defineMessages({
defaultMessage: 'Notifications for certain activities are enabled by default,', defaultMessage: 'Notifications for certain activities are enabled by default,',
description: 'Body of the notification preferences for learner guide', description: 'Body of the notification preferences for learner guide',
}, },
accountNotificationDescription: {
id: 'account.notification.description',
defaultMessage: 'Account-level settings apply to all courses. Notifications for individual courses can be changed within each course and will override account-level settings.',
description: 'Account notification description',
},
notificationCadenceDescription: {
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; export default messages;

View File

@@ -13,7 +13,7 @@ describe('MockedPluginSlot', () => {
it('renders as the slot children directly if there is content within', () => { it('renders as the slot children directly if there is content within', () => {
render( render(
<div role="article"> <div role="article">
<MockedPluginSlot> <MockedPluginSlot id="test_plugin">
<q role="note">How much wood could a woodchuck chuck if a woodchuck could chuck wood?</q> <q role="note">How much wood could a woodchuck chuck if a woodchuck could chuck wood?</q>
</MockedPluginSlot> </MockedPluginSlot>
</div>, </div>,
@@ -21,9 +21,13 @@ describe('MockedPluginSlot', () => {
const component = screen.getByRole('article'); const component = screen.getByRole('article');
expect(component).toBeInTheDocument(); expect(component).toBeInTheDocument();
const slot = component.querySelector('[data-testid="test_plugin"]');
// Direct children expect(slot).toBeInTheDocument();
const quote = component.querySelector(':scope > q'); 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?');
expect(quote.getAttribute('role')).toBe('note'); expect(quote.getAttribute('role')).toBe('note');
}); });