Compare commits
15 Commits
andal-lnd/
...
sundasAcco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42342c2b18 | ||
|
|
5a5de29dea | ||
|
|
5381719477 | ||
|
|
235dd9c462 | ||
|
|
301797aca3 | ||
|
|
27c686fbe3 | ||
|
|
45bcfddab6 | ||
|
|
45b0734e83 | ||
|
|
8db7c54119 | ||
|
|
f04fddd8c0 | ||
|
|
84de80eb7f | ||
|
|
7618004054 | ||
|
|
3b2cf2717e | ||
|
|
dbb374860d | ||
|
|
aeff548b82 |
2
.env
2
.env
@@ -32,4 +32,4 @@ APP_ID=
|
||||
MFE_CONFIG_API_URL=
|
||||
PASSWORD_RESET_SUPPORT_LINK=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
|
||||
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'
|
||||
|
||||
@@ -33,4 +33,4 @@ APP_ID=
|
||||
MFE_CONFIG_API_URL=
|
||||
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
|
||||
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'
|
||||
|
||||
@@ -30,4 +30,4 @@ MARKETING_EMAILS_OPT_IN=''
|
||||
APP_ID=
|
||||
MFE_CONFIG_API_URL=
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
|
||||
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'
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -2010,9 +2010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.7.0.tgz",
|
||||
"integrity": "sha512-a7ErsU0m6yaU8VGLN4JYC1Y43W5L/zSCZSmpDZw534LiPK43k4eyU8u5Ep1yxp+8sOvB49qED4huIq+1oVDJsA==",
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.7.1.tgz",
|
||||
"integrity": "sha512-3a7JcoqW1jUGbOoEoi8+COlxKLAd+DigAPiUNwcuefmGKYN05pF/vRc6yt0W03rHLYkq7A23CFyoqhZC44GTZg==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
@@ -3367,9 +3367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/frontend-slot-footer": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/frontend-slot-footer/-/frontend-slot-footer-1.0.5.tgz",
|
||||
"integrity": "sha512-hlKj2p7zgAHfPTbmW4rAMUCCj35uCbm9VfRkZYPEIX2bdhkPjc4OIGTE5CKGkyWG5+zL+aap1ywwcKEjEgOMOQ==",
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/frontend-slot-footer/-/frontend-slot-footer-1.0.6.tgz",
|
||||
"integrity": "sha512-6wdbxYeSzpUOaZrnglkAI0PJ/CB8pMP3xbYXQNQ+VR4U8pkH8EFwEQjwak4OVmodS8//eW9xgYv79YB4H7mGEQ==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@openedx/frontend-plugin-framework": "^1.1.2"
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from './data/constants';
|
||||
import { fetchSiteLanguages } from './site-language';
|
||||
import { fetchCourseList } from '../notification-preferences/data/thunks';
|
||||
import NotificationSettings from '../notification-preferences/NotificationSettings';
|
||||
import { withLocation, withNavigate } from './hoc';
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
@@ -120,7 +121,15 @@ class AccountSettingsPage extends React.Component {
|
||||
countryOptions: [{
|
||||
value: '',
|
||||
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: [{
|
||||
value: '',
|
||||
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
|
||||
@@ -147,11 +156,28 @@ class AccountSettingsPage extends React.Component {
|
||||
})),
|
||||
}));
|
||||
|
||||
removeDisabledCountries = (countryList) => {
|
||||
const { disabledCountries, committedValues } = this.props;
|
||||
|
||||
if (!disabledCountries.length) {
|
||||
return countryList;
|
||||
}
|
||||
|
||||
return countryList.filter(({ value, disabled }) => {
|
||||
const isUserCountry = value === committedValues.country;
|
||||
return !disabled || isUserCountry;
|
||||
});
|
||||
};
|
||||
|
||||
handleEditableFieldChange = (name, value) => {
|
||||
this.props.updateDraft(name, value);
|
||||
};
|
||||
|
||||
handleSubmit = (formId, values) => {
|
||||
if (formId === 'country' && this.isDisabledCountry(values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { formValues } = this.props;
|
||||
let extendedProfileObject = {};
|
||||
|
||||
@@ -193,6 +219,11 @@ class AccountSettingsPage extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
isDisabledCountry = (country) => {
|
||||
const { disabledCountries } = this.props;
|
||||
return disabledCountries.includes(country);
|
||||
};
|
||||
|
||||
isEditable(fieldName) {
|
||||
return !this.props.staticFields.includes(fieldName);
|
||||
}
|
||||
@@ -466,7 +497,8 @@ class AccountSettingsPage extends React.Component {
|
||||
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
|
||||
|
||||
// Show State field only if the country is US (could include Canada later)
|
||||
const 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 hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
|
||||
@@ -696,7 +728,7 @@ class AccountSettingsPage extends React.Component {
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="account-section pt-3 mb-5" 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>
|
||||
@@ -732,8 +764,10 @@ class AccountSettingsPage extends React.Component {
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="account-section pt-3 mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
|
||||
<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'])}
|
||||
</h2>
|
||||
@@ -870,6 +904,7 @@ AccountSettingsPage.propTypes = {
|
||||
name: PropTypes.string,
|
||||
useVerifiedNameForCerts: PropTypes.bool,
|
||||
verified_name: PropTypes.string,
|
||||
country: PropTypes.string,
|
||||
}),
|
||||
drafts: PropTypes.shape({}),
|
||||
formErrors: PropTypes.shape({
|
||||
@@ -928,6 +963,7 @@ AccountSettingsPage.propTypes = {
|
||||
),
|
||||
navigate: PropTypes.func.isRequired,
|
||||
location: PropTypes.string.isRequired,
|
||||
disabledCountries: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
AccountSettingsPage.defaultProps = {
|
||||
@@ -937,6 +973,7 @@ AccountSettingsPage.defaultProps = {
|
||||
committedValues: {
|
||||
useVerifiedNameForCerts: false,
|
||||
verified_name: null,
|
||||
country: '',
|
||||
},
|
||||
drafts: {},
|
||||
formErrors: {},
|
||||
@@ -953,6 +990,7 @@ AccountSettingsPage.defaultProps = {
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: [],
|
||||
disabledCountries: [],
|
||||
};
|
||||
|
||||
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
||||
|
||||
@@ -107,6 +107,7 @@ const EditableSelectField = (props) => {
|
||||
<option
|
||||
value={subOption.value}
|
||||
key={`${subOption.value}-${subOption.label}`}
|
||||
disabled={subOption?.disabled}
|
||||
>
|
||||
{subOption.label}
|
||||
</option>
|
||||
@@ -115,7 +116,7 @@ const EditableSelectField = (props) => {
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon';
|
||||
import { OpenInNew } from '@openedx/paragon/icons';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { NavHashLink } from 'react-router-hash-link';
|
||||
import Scrollspy from 'react-scrollspy';
|
||||
import { Link } from 'react-router-dom';
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
import { selectShowPreferences } from '../notification-preferences/data/selectors';
|
||||
|
||||
const JumpNav = ({
|
||||
intl,
|
||||
}) => {
|
||||
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
||||
const showPreferences = useSelector(selectShowPreferences());
|
||||
|
||||
return (
|
||||
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
|
||||
@@ -65,21 +60,6 @@ const JumpNav = ({
|
||||
</li>
|
||||
)}
|
||||
</Scrollspy>
|
||||
{showPreferences && (
|
||||
<>
|
||||
<hr />
|
||||
<Scrollspy
|
||||
className="list-unstyled"
|
||||
>
|
||||
<li>
|
||||
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
|
||||
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
|
||||
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
|
||||
</Link>
|
||||
</li>
|
||||
</Scrollspy>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ export const defaultState = {
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: {},
|
||||
disabledCountries: ['RU'],
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action = {}) => {
|
||||
|
||||
@@ -206,6 +206,11 @@ const activeAccountSelector = createSelector(
|
||||
accountSettings => accountSettings.values.is_active,
|
||||
);
|
||||
|
||||
const disabledCountriesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.disabledCountries,
|
||||
);
|
||||
|
||||
export const siteLanguageSelector = createSelector(
|
||||
previousSiteLanguageSelector,
|
||||
draftsSelector,
|
||||
@@ -237,6 +242,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
mostRecentApprovedVerifiedNameValueSelector,
|
||||
mostRecentVerifiedNameSelector,
|
||||
sortedVerifiedNameHistorySelector,
|
||||
disabledCountriesSelector,
|
||||
(
|
||||
accountSettings,
|
||||
siteLanguageOptions,
|
||||
@@ -254,6 +260,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
disabledCountries,
|
||||
) => ({
|
||||
siteLanguageOptions,
|
||||
siteLanguage,
|
||||
@@ -274,6 +281,7 @@ export const accountSettingsPageSelector = createSelector(
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
disabledCountries,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export class DeleteAccount extends React.Component {
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
|
||||
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
@@ -116,7 +116,7 @@ export class DeleteAccount extends React.Component {
|
||||
{isVerifiedAccount ? null : (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId={optInInstructionMessageId}
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const PrintingInstructions = (props) => {
|
||||
// TODO: What would a generic version of this link look like? Should
|
||||
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
||||
// We've removed the link from the default message.
|
||||
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
|
||||
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
|
||||
>
|
||||
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
|
||||
</Hyperlink>
|
||||
|
||||
@@ -27,7 +27,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
<p>
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
@@ -74,7 +74,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<p>
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
@@ -117,7 +117,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
Before proceeding, please
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
|
||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
@@ -156,7 +156,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<p>
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
@@ -199,7 +199,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
Before proceeding, please
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/articles/207206067"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -71,6 +71,16 @@ describe('AccountSettingsPage', () => {
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
beforeAll(() => {
|
||||
global.lightningjs = {
|
||||
require: jest.fn().mockImplementation((module, url) => ({ moduleName: module, url })),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete global.lightningjs;
|
||||
});
|
||||
|
||||
it('renders AccountSettingsPage correctly with editing enabled', async () => {
|
||||
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const mockData = {
|
||||
profileDataManager: null,
|
||||
},
|
||||
notificationPreferences: {
|
||||
showPreferences: false,
|
||||
showPreferences: true,
|
||||
courses: {
|
||||
status: 'success',
|
||||
courses: [],
|
||||
@@ -98,7 +98,7 @@ const mockData = {
|
||||
preferences: {
|
||||
status: 'idle',
|
||||
updatePreferenceStatus: 'idle',
|
||||
selectedCourse: null,
|
||||
selectedCourse: 'account',
|
||||
preferences: [],
|
||||
apps: [],
|
||||
nonEditable: {},
|
||||
|
||||
16
src/divider/Divider.jsx
Normal file
16
src/divider/Divider.jsx
Normal 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
2
src/divider/index.jsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as Divider } from './Divider';
|
||||
@@ -20,8 +20,6 @@ import messages from './i18n';
|
||||
|
||||
import './index.scss';
|
||||
import Head from './head/Head';
|
||||
import NotificationCourses from './notification-preferences/NotificationCourses';
|
||||
import NotificationPreferences from './notification-preferences/NotificationPreferences';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
@@ -38,8 +36,6 @@ subscribe(APP_READY, () => {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Route path="/notifications/:courseId" element={<NotificationPreferences />} />
|
||||
<Route path="/notifications" element={<NotificationCourses />} />
|
||||
<Route
|
||||
path="/id-verification/*"
|
||||
element={<IdVerificationPageSlot />}
|
||||
|
||||
@@ -118,7 +118,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
}
|
||||
|
||||
.dropdown-item:active,
|
||||
.dropdown-item:focus,
|
||||
.dropdown-item:focus,
|
||||
.btn-tertiary:not(:disabled):not(.disabled).active {
|
||||
background-color: $light-300 !important;
|
||||
}
|
||||
@@ -131,6 +131,20 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
.h-4\.5 {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.course-dropdown{
|
||||
#course-dropdown-btn {
|
||||
width: 100%;
|
||||
font-size: 14px !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
border: 1px solid $light-500 !important;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usabilla_live_button_container {
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
73
src/notification-preferences/NotificationCoursesDropdown.jsx
Normal file
73
src/notification-preferences/NotificationCoursesDropdown.jsx
Normal 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;
|
||||
@@ -11,27 +11,22 @@ import { useIsOnMobile } from '../hooks';
|
||||
import NotificationTypes from './NotificationTypes';
|
||||
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
|
||||
import NotificationPreferenceColumn from './NotificationPreferenceColumn';
|
||||
import { selectPreferenceAppToggleValue, selectSelectedCourseId, selectAppPreferences } from './data/selectors';
|
||||
import { selectPreferenceAppToggleValue, selectAppPreferences } from './data/selectors';
|
||||
|
||||
const NotificationPreferenceApp = ({ appId }) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useSelector(selectSelectedCourseId());
|
||||
const appToggle = useSelector(selectPreferenceAppToggleValue(appId));
|
||||
const appPreferences = useSelector(selectAppPreferences(appId));
|
||||
const mobileView = useIsOnMobile();
|
||||
const NOTIFICATION_CHANNELS = notificationChannels();
|
||||
const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false;
|
||||
|
||||
if (!courseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
!hideAppPreferences && (
|
||||
<Collapsible.Advanced
|
||||
open={appToggle}
|
||||
data-testid={`${appId}-app`}
|
||||
className={classNames({ 'mb-5': !mobileView && appToggle })}
|
||||
className={classNames({ 'mb-4.5': !mobileView && appToggle })}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<div className="d-flex align-items-center">
|
||||
|
||||
@@ -28,7 +28,9 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
|
||||
const onToggle = useCallback((event, notificationType) => {
|
||||
const { name: notificationChannel } = event.target;
|
||||
const value = notificationChannel === 'email_cadence' ? event.target.innerText : event.target.checked;
|
||||
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,
|
||||
@@ -36,9 +38,10 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appId]);
|
||||
}, [appId, appPreferences]);
|
||||
|
||||
const renderPreference = (preference) => (
|
||||
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Hyperlink, Icon, Spinner, NavItem,
|
||||
} from '@openedx/paragon';
|
||||
import { Spinner, NavItem } from '@openedx/paragon';
|
||||
|
||||
import { useIsOnMobile } from '../hooks';
|
||||
import messages from './messages';
|
||||
import { NotFoundPage } from '../account-settings';
|
||||
import NotificationPreferenceApp from './NotificationPreferenceApp';
|
||||
import { fetchCourseList, fetchCourseNotificationPreferences } from './data/thunks';
|
||||
import { fetchCourseNotificationPreferences } from './data/thunks';
|
||||
import { LOADING_STATUS } from '../constants';
|
||||
import {
|
||||
FAILURE_STATUS, IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS,
|
||||
} from '../constants';
|
||||
import {
|
||||
selectCourse, selectCourseList, selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId,
|
||||
selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId, selectSelectedCourseId,
|
||||
} from './data/selectors';
|
||||
import { notificationChannels } from './data/utils';
|
||||
|
||||
const NotificationPreferences = () => {
|
||||
const { courseId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const courseStatus = useSelector(selectCourseListStatus());
|
||||
const coursesList = useSelector(selectCourseList());
|
||||
const course = useSelector(selectCourse(courseId));
|
||||
const courseId = useSelector(selectSelectedCourseId());
|
||||
const notificationStatus = useSelector(selectNotificationPreferencesStatus());
|
||||
const preferenceAppsIds = useSelector(selectPreferenceAppsId());
|
||||
const mobileView = useIsOnMobile();
|
||||
@@ -43,46 +34,16 @@ const NotificationPreferences = () => {
|
||||
), [preferenceAppsIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if ([IDLE_STATUS, FAILURE_STATUS].includes(courseStatus)) {
|
||||
dispatch(fetchCourseList());
|
||||
}
|
||||
dispatch(fetchCourseNotificationPreferences(courseId));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [courseId]);
|
||||
}, [courseId, dispatch]);
|
||||
|
||||
if (
|
||||
(courseStatus === SUCCESS_STATUS && coursesList.length === 0)
|
||||
|| (notificationStatus === FAILURE_STATUS && coursesList.length !== 0)
|
||||
) {
|
||||
return <NotFoundPage />;
|
||||
if (preferenceAppsIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm" className="notification-preferences">
|
||||
<h2 className="notification-heading mt-6 mb-4.5">
|
||||
{intl.formatMessage(messages.notificationHeading)}
|
||||
</h2>
|
||||
<div className="mb-6 text-gray-700 font-size-14 margin-bottom-32">
|
||||
{intl.formatMessage(messages.notificationPreferenceGuideBody)}
|
||||
<Hyperlink
|
||||
destination="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/sfd_notifications/managing_notifications.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-underline ml-1"
|
||||
>
|
||||
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<div className="h-100">
|
||||
<div className="d-flex mb-5">
|
||||
<Link to="/notifications">
|
||||
<Icon className="text-primary-500" src={ArrowBack} />
|
||||
</Link>
|
||||
<span className="notification-course-title ml-auto mr-auto text-primary-500">
|
||||
{course?.name}
|
||||
</span>
|
||||
</div>
|
||||
{!mobileView && !isLoading && (
|
||||
<div className="h-100">
|
||||
{!mobileView && !isLoading && (
|
||||
<div className="d-flex flex-row justify-content-between float-right">
|
||||
<div className="d-flex">
|
||||
{Object.values(NOTIFICATION_CHANNELS).map((channel) => (
|
||||
@@ -103,21 +64,20 @@ const NotificationPreferences = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{preferencesList}
|
||||
{isLoading && (
|
||||
<div className="d-flex">
|
||||
<Spinner
|
||||
variant="primary"
|
||||
animation="border"
|
||||
className="mx-auto my-auto"
|
||||
size="lg"
|
||||
data-testid="loading-spinner"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
{preferencesList}
|
||||
{isLoading && (
|
||||
<div className="d-flex">
|
||||
<Spinner
|
||||
variant="primary"
|
||||
animation="border"
|
||||
className="mx-auto my-auto"
|
||||
size="lg"
|
||||
data-testid="loading-spinner"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { defaultState } from './data/reducers';
|
||||
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';
|
||||
|
||||
@@ -77,6 +77,7 @@ const setupStore = (override = {}) => {
|
||||
storeState.courses = {
|
||||
status: SUCCESS_STATUS,
|
||||
courses: [
|
||||
{ id: '', name: 'Account' },
|
||||
{ id: 'selected-course-id', name: 'Selected Course' },
|
||||
],
|
||||
};
|
||||
@@ -146,9 +147,15 @@ describe('Notification Preferences', () => {
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('show not found page if invalid course id is entered in url', async () => {
|
||||
store = setupStore({ status: FAILURE_STATUS, selectedCourse: 'invalid-course-id' });
|
||||
it('update account preference on click', async () => {
|
||||
store = setupStore({
|
||||
...defaultPreferences,
|
||||
status: SUCCESS_STATUS,
|
||||
selectedCourse: '',
|
||||
});
|
||||
await render(notificationPreferences(store));
|
||||
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
|
||||
const element = screen.getByTestId('core-web');
|
||||
await fireEvent.click(element);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
52
src/notification-preferences/NotificationSettings.jsx
Normal file
52
src/notification-preferences/NotificationSettings.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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: '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/managing_notifications.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-underline ml-1"
|
||||
>
|
||||
{intl.formatMessage(messages.notificationPreferenceGuideLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<NotificationCoursesDropdown />
|
||||
<NotificationPreferences courseId={courseId} />
|
||||
</Container>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
@@ -10,8 +10,10 @@ export const Actions = {
|
||||
UPDATE_APP_PREFERENCE: 'updateAppValue',
|
||||
};
|
||||
|
||||
export const fetchNotificationPreferenceSuccess = (courseId, payload) => dispatch => (
|
||||
dispatch({ type: Actions.FETCHED_PREFERENCES, courseId, payload })
|
||||
export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => (
|
||||
dispatch({
|
||||
type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference,
|
||||
})
|
||||
);
|
||||
|
||||
export const fetchNotificationPreferenceFetching = () => dispatch => (
|
||||
|
||||
@@ -5,18 +5,19 @@ import {
|
||||
SUCCESS_STATUS,
|
||||
FAILURE_STATUS,
|
||||
} from '../../constants';
|
||||
import { normalizeAccountPreferences } from './thunks';
|
||||
|
||||
export const defaultState = {
|
||||
showPreferences: false,
|
||||
courses: {
|
||||
status: IDLE_STATUS,
|
||||
courses: [],
|
||||
courses: [{ id: '', name: 'Account' }],
|
||||
pagination: {},
|
||||
},
|
||||
preferences: {
|
||||
status: IDLE_STATUS,
|
||||
updatePreferenceStatus: IDLE_STATUS,
|
||||
selectedCourse: null,
|
||||
selectedCourse: '',
|
||||
preferences: [],
|
||||
apps: [],
|
||||
nonEditable: {},
|
||||
@@ -66,15 +67,22 @@ const notificationPreferencesReducer = (state = defaultState, action = {}) => {
|
||||
},
|
||||
};
|
||||
case Actions.FETCHED_PREFERENCES:
|
||||
{
|
||||
const { preferences } = state;
|
||||
if (action.isAccountPreference) {
|
||||
normalizeAccountPreferences(preferences, action.payload);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
...preferences,
|
||||
status: SUCCESS_STATUS,
|
||||
updatePreferenceStatus: SUCCESS_STATUS,
|
||||
...action.payload,
|
||||
},
|
||||
};
|
||||
}
|
||||
case Actions.FAILED_PREFERENCES:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -36,9 +36,7 @@ describe('notification-preferences reducer', () => {
|
||||
hasMore: false,
|
||||
totalPages: 1,
|
||||
},
|
||||
courseList: [
|
||||
{ id: selectedCourseId, name: 'Selected Course' },
|
||||
],
|
||||
courseList: [],
|
||||
};
|
||||
const result = reducer(
|
||||
state,
|
||||
@@ -46,7 +44,7 @@ describe('notification-preferences reducer', () => {
|
||||
);
|
||||
expect(result.courses).toEqual({
|
||||
status: SUCCESS_STATUS,
|
||||
courses: data.courseList,
|
||||
courses: [{ id: '', name: 'Account' }],
|
||||
pagination: data.pagination,
|
||||
});
|
||||
});
|
||||
@@ -61,7 +59,10 @@ describe('notification-preferences reducer', () => {
|
||||
);
|
||||
expect(result.courses).toEqual({
|
||||
status,
|
||||
courses: [],
|
||||
courses: [{
|
||||
id: '',
|
||||
name: 'Account',
|
||||
}],
|
||||
pagination: {},
|
||||
});
|
||||
});
|
||||
@@ -82,7 +83,7 @@ describe('notification-preferences reducer', () => {
|
||||
expect(result.preferences).toEqual({
|
||||
status: SUCCESS_STATUS,
|
||||
updatePreferenceStatus: SUCCESS_STATUS,
|
||||
selectedCourse: null,
|
||||
selectedCourse: '',
|
||||
...preferenceData,
|
||||
});
|
||||
});
|
||||
@@ -97,7 +98,7 @@ describe('notification-preferences reducer', () => {
|
||||
);
|
||||
expect(result.preferences).toEqual({
|
||||
status,
|
||||
selectedCourse: null,
|
||||
selectedCourse: '',
|
||||
preferences: [],
|
||||
apps: [],
|
||||
nonEditable: {},
|
||||
|
||||
@@ -32,3 +32,22 @@ export const patchPreferenceToggle = async (
|
||||
const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const postPreferenceToggle = async (
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
) => {
|
||||
const patchData = snakeCaseObject({
|
||||
notificationApp,
|
||||
notificationType: snakeCase(notificationType),
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
});
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update-all/`;
|
||||
const { data } = await getAuthenticatedHttpClient().post(url, patchData);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import EMAIL_CADENCE from './constants';
|
||||
import {
|
||||
fetchCourseListSuccess,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getCourseList,
|
||||
getCourseNotificationPreferences,
|
||||
patchPreferenceToggle,
|
||||
postPreferenceToggle,
|
||||
} from './service';
|
||||
|
||||
const normalizeCourses = (responseData) => {
|
||||
@@ -36,8 +38,29 @@ const normalizeCourses = (responseData) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePreferences = (responseData) => {
|
||||
const preferences = responseData.notificationPreferenceConfig;
|
||||
export const normalizeAccountPreferences = (originalData, updateInfo) => {
|
||||
const {
|
||||
app, notificationType, channel, updatedValue,
|
||||
} = updateInfo.data;
|
||||
|
||||
const preferenceToUpdate = originalData.preferences.find(
|
||||
(preference) => preference.appId === app && preference.id === camelCase(notificationType),
|
||||
);
|
||||
|
||||
if (preferenceToUpdate) {
|
||||
preferenceToUpdate[camelCase(channel)] = updatedValue;
|
||||
}
|
||||
|
||||
return originalData;
|
||||
};
|
||||
|
||||
const normalizePreferences = (responseData, courseId) => {
|
||||
let preferences;
|
||||
if (courseId) {
|
||||
preferences = responseData.notificationPreferenceConfig;
|
||||
} else {
|
||||
preferences = responseData.data;
|
||||
}
|
||||
|
||||
const appKeys = Object.keys(preferences);
|
||||
const apps = appKeys.map((appId) => ({
|
||||
@@ -92,7 +115,7 @@ export const fetchCourseNotificationPreferences = (courseId) => (
|
||||
dispatch(updateSelectedCourse(courseId));
|
||||
dispatch(fetchNotificationPreferenceFetching());
|
||||
const data = await getCourseNotificationPreferences(courseId);
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data));
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
||||
} catch (errors) {
|
||||
dispatch(fetchNotificationPreferenceFailed());
|
||||
@@ -100,12 +123,19 @@ export const fetchCourseNotificationPreferences = (courseId) => (
|
||||
}
|
||||
);
|
||||
|
||||
export const setSelectedCourse = courseId => (
|
||||
async (dispatch) => {
|
||||
dispatch(updateSelectedCourse(courseId));
|
||||
}
|
||||
);
|
||||
|
||||
export const updatePreferenceToggle = (
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
) => (
|
||||
async (dispatch) => {
|
||||
try {
|
||||
@@ -115,15 +145,27 @@ export const updatePreferenceToggle = (
|
||||
notificationChannel,
|
||||
!value,
|
||||
));
|
||||
const data = await patchPreferenceToggle(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
);
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data));
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
||||
let data = null;
|
||||
if (courseId) {
|
||||
data = await patchPreferenceToggle(
|
||||
courseId,
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
);
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data), courseId);
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
||||
} else {
|
||||
data = await postPreferenceToggle(
|
||||
notificationApp,
|
||||
notificationType,
|
||||
notificationChannel,
|
||||
value,
|
||||
emailCadence,
|
||||
);
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, camelCaseObject(data), true));
|
||||
}
|
||||
} catch (errors) {
|
||||
dispatch(updatePreferenceValue(
|
||||
notificationApp,
|
||||
|
||||
@@ -90,6 +90,36 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Notifications for certain activities are enabled by default,',
|
||||
description: 'Body of the notification preferences for learner guide',
|
||||
},
|
||||
accountNotificationDescription: {
|
||||
id: 'account.notification.description',
|
||||
defaultMessage: 'Account-level settings apply to all courses. Notifications for individual courses can be changed within each course and will override account-level settings.',
|
||||
description: 'Account notification description',
|
||||
},
|
||||
notificationCadenceDescription: {
|
||||
id: 'notification.cadence.description',
|
||||
defaultMessage: 'Daily notifications are delivered at {dailyTime}. Weekly notifications are delivered at {weeklyTime}.',
|
||||
description: 'Notification cadence description',
|
||||
},
|
||||
notificationDefaultInfo: {
|
||||
id: 'notification.default.info',
|
||||
defaultMessage: 'Notifications for certain activities are enabled by default, as detailed here',
|
||||
description: 'Default notification info',
|
||||
},
|
||||
notificationDropdownlabel: {
|
||||
id: 'notification.dropdown.label',
|
||||
defaultMessage: 'Select notifications for',
|
||||
description: 'Dropdown label',
|
||||
},
|
||||
notificationDropdownApplies: {
|
||||
id: 'notification.dropdown.applies',
|
||||
defaultMessage: 'Applies to all courses',
|
||||
description: 'Dropdown applies to all courses',
|
||||
},
|
||||
notificationCourseDropdownApplies: {
|
||||
id: 'notification.dropdown.course.applies',
|
||||
defaultMessage: 'Overrides account-wide settings',
|
||||
description: 'Dropdown applies to specific course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user