feat: added notification preferences settings at account level

This commit is contained in:
Awais Ansari
2024-11-14 13:39:41 +05:00
parent 45bcfddab6
commit 27c686fbe3
15 changed files with 219 additions and 278 deletions

View File

@@ -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 {
@@ -727,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>
@@ -763,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>

View File

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

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

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

View File

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

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,69 @@
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">{intl.formatMessage(messages.notificationDropdownApplies)}</span>
</div>
)
);
};
export default NotificationCoursesDropdown;

View File

@@ -31,7 +31,7 @@ const NotificationPreferenceApp = ({ appId }) => {
<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">

View File

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

View 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 />
{courseId && <NotificationPreferences courseId={courseId} />}
</Container>
)
);
};
export default NotificationSettings;

View File

@@ -10,13 +10,13 @@ export const defaultState = {
showPreferences: false,
courses: {
status: IDLE_STATUS,
courses: [],
courses: [{ id: 'account', name: 'Account' }],
pagination: {},
},
preferences: {
status: IDLE_STATUS,
updatePreferenceStatus: IDLE_STATUS,
selectedCourse: null,
selectedCourse: 'account',
preferences: [],
apps: [],
nonEditable: {},

View File

@@ -100,6 +100,12 @@ export const fetchCourseNotificationPreferences = (courseId) => (
}
);
export const setSelectedCourse = courseId => (
async (dispatch) => {
dispatch(updateSelectedCourse(courseId));
}
);
export const updatePreferenceToggle = (
courseId,
notificationApp,

View File

@@ -90,6 +90,31 @@ 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',
},
});
export default messages;