feat: added notification preference ui (#784)

This commit is contained in:
Muhammad Adeel Tajamul
2023-05-22 06:17:51 +05:00
committed by GitHub
parent f2f761e8db
commit 192714629c
18 changed files with 727 additions and 6 deletions

View File

@@ -819,12 +819,12 @@ class AccountSettingsPage extends React.Component {
</h1>
<div>
<div className="row">
<div className="col-md-3">
<div className="col-md-2">
<JumpNav
displayDemographicsLink={this.props.formValues.shouldDisplayDemographicsSection}
/>
</div>
<div className="col-md-9">
<div className="col-md-10">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}

View File

@@ -565,6 +565,11 @@ const messages = defineMessages({
defaultMessage: 'No value set.',
description: 'The placeholder for an empty but uneditable field when there is no administrator',
},
'notification.preferences.notifications.label': {
id: 'notification.preferences.notifications.label',
defaultMessage: 'Notifications',
description: 'Label for Notifications',
},
});
export default messages;

View File

@@ -1,11 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { breakpoints, useWindowSize, Icon } from '@edx/paragon';
import { OpenInNew } from '@edx/paragon/icons';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import { Link } from 'react-router-dom';
import messages from './AccountSettingsPage.messages';
const JumpNav = ({
@@ -13,8 +15,9 @@ const JumpNav = ({
displayDemographicsLink,
}) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
const showNotificationMenu = false;
return (
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<Scrollspy
items={[
'basic-information',
@@ -67,6 +70,22 @@ const JumpNav = ({
</NavHashLink>
</li>
</Scrollspy>
{showNotificationMenu
&& (
<>
<hr />
<Scrollspy
className="list-unstyled"
>
<li>
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
</Link>
</li>
</Scrollspy>
</>
)}
</div>
);
};

View File

@@ -2,7 +2,7 @@
exports[`JumpNav should not render Optional Information link 1`] = `
<div
className="jump-nav jump-nav-sm position-sticky pt-3"
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
>
<ul
className="list-unstyled"
@@ -80,7 +80,7 @@ exports[`JumpNav should not render Optional Information link 1`] = `
exports[`JumpNav should render Optional Information link 1`] = `
<div
className="jump-nav jump-nav-sm position-sticky pt-3"
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
>
<ul
className="list-unstyled"

View File

@@ -4,8 +4,10 @@ import {
reducer as accountSettingsReducer,
storeName as accountSettingsStoreName,
} from '../account-settings';
import notificationPreferencesReducer from '../notification-preferences/data/reducers';
const createRootReducer = () => combineReducers({
[accountSettingsStoreName]: accountSettingsReducer,
notificationPreferences: notificationPreferencesReducer,
});
export default createRootReducer;

View File

@@ -21,8 +21,11 @@ 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, () => {
const allowNotificationRoutes = false;
ReactDOM.render(
<AppProvider store={configureStore()}>
<Head />
@@ -32,6 +35,12 @@ subscribe(APP_READY, () => {
<Header />
<main className="flex-grow-1">
<Switch>
{allowNotificationRoutes && (
<>
<Route path="/notifications/:courseId" component={NotificationPreferences} />
<Route path="/notifications" component={NotificationCourses} />
</>
)}
<Route path="/id-verification" component={IdVerificationPage} />
<Route exact path="/" component={AccountSettingsPage} />
<Route path="/notfound" component={NotFoundPage} />

View File

@@ -62,3 +62,27 @@ $fa-font-path: "~font-awesome/fonts";
font-weight: normal;
}
}
.notification-heading {
line-height: 36px;
font-weight: 700;
font-size: 32px;
}
.notification-course-title {
line-height: 28px;
font-weight: 700;
font-size: 18px;
}
.px-2\.25 {
padding-left: 0.625rem;
}
.notification-help-text {
font-size: 14px;
font-weight: 400;
line-height: 28px;
height: 28px;
color: #707070;
}

View File

@@ -0,0 +1,65 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Container, Icon, Spinner } from '@edx/paragon';
import { ArrowForwardIos } from '@edx/paragon/icons';
import { fetchCourseList } from './data/thunks';
import { courseListStatus, getCourseList } from './data/selectors';
import { IDLE_STATUS, LOADING_STATUS } from '../constants';
import { messages } from './messages';
const NotificationCourses = ({ intl }) => {
const dispatch = useDispatch();
const courseStatus = useSelector(courseListStatus());
const coursesList = useSelector(getCourseList());
useEffect(() => {
if (courseStatus === IDLE_STATUS || coursesList.length === 0) {
dispatch(fetchCourseList());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseStatus]);
if (courseStatus === LOADING_STATUS) {
return (
<div className="d-flex h-100">
<Spinner
variant="primary"
animation="border"
className="mx-auto my-auto"
style={{ width: '4rem', height: '4rem' }}
/>
</div>
);
}
return (
<Container size="md">
<h2 className="notification-heading mt-6 mb-5.5">
{intl.formatMessage(messages.notificationHeading)}
</h2>
<div>
{
coursesList.map(course => (
<Link
to={`/notifications/${course.id}`}
>
<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>
</Container>
);
};
NotificationCourses.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(NotificationCourses);

View File

@@ -0,0 +1,74 @@
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible } from '@edx/paragon';
import { messages } from './messages';
import ToggleSwitch from './ToggleSwitch';
import {
getPreferenceGroup,
getSelectedCourse,
} from './data/selectors';
import NotificationPreferenceRow from './NotificationPreferenceRow';
import { updateGroupValue } from './data/actions';
const NotificationPreferenceGroup = ({ groupId }) => {
const dispatch = useDispatch();
const intl = useIntl();
const courseId = useSelector(getSelectedCourse());
const preferenceGroup = useSelector(getPreferenceGroup(groupId));
const [groupToggle, setGroupToggle] = useState(true);
const preferences = useMemo(() => (
preferenceGroup.map(preference => (
<NotificationPreferenceRow
key={preference.id}
groupId={groupId}
preferenceName={preference.id}
/>
))), [groupId, preferenceGroup]);
const onChangeGroupSettings = useCallback((checked) => {
setGroupToggle(checked);
dispatch(updateGroupValue(courseId, groupId, checked));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupId]);
if (!courseId) {
return null;
}
return (
<Collapsible.Advanced open={groupToggle}>
<Collapsible.Trigger>
<div className="d-flex">
<span className="ml-0 mr-auto">
{intl.formatMessage(messages.notificationGroupTitle, { key: groupId })}
</span>
<span className="ml-auto mr-0">
<ToggleSwitch value={groupToggle} onChange={onChangeGroupSettings} />
</span>
</div>
<hr />
</Collapsible.Trigger>
<Collapsible.Body>
<div className="d-flex flex-row notification-help-text">
<span className="col-8 px-0">{intl.formatMessage(messages.notificationHelpType)}</span>
<span className="d-flex col-4 px-0">
<span className="ml-0 mr-auto">{intl.formatMessage(messages.notificationHelpWeb)}</span>
<span className="mx-auto">{intl.formatMessage(messages.notificationHelpEmail)}</span>
<span className="ml-auto mr-0 pr-2.5">{intl.formatMessage(messages.notificationHelpPush)}</span>
</span>
</div>
<div className="mt-3 pb-5">
{ preferences }
</div>
</Collapsible.Body>
</Collapsible.Advanced>
);
};
NotificationPreferenceGroup.propTypes = {
groupId: PropTypes.string.isRequired,
};
export default React.memo(NotificationPreferenceGroup);

View File

@@ -0,0 +1,51 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { messages } from './messages';
import ToggleSwitch from './ToggleSwitch';
import { getPreferenceAttribute } from './data/selectors';
import { updatePreferenceValue } from './data/actions';
const NotificationPreferenceRow = ({ groupId, preferenceName }) => {
const dispatch = useDispatch();
const intl = useIntl();
const preference = useSelector(getPreferenceAttribute(groupId, preferenceName));
const onToggle = useCallback((checked, notificationChannel) => {
dispatch(updatePreferenceValue(groupId, preferenceName, notificationChannel, checked));
}, [dispatch, groupId, preferenceName]);
return (
<div className="d-flex flex-row mb-3">
<span className="col-8 px-0">
{intl.formatMessage(messages.notificationTitle, { text: preferenceName })}
</span>
<span className="d-flex col-4 px-0">
<span className="ml-0 mr-auto">
<ToggleSwitch
value={preference.web}
onChange={(checked) => onToggle(checked, 'web')}
/>
</span>
<span className="mx-auto">
<ToggleSwitch
value={preference.email}
onChange={(checked) => onToggle(checked, 'email')}
/>
</span>
<span className="ml-auto mr-0">
<ToggleSwitch
value={preference.push}
onChange={(checked) => onToggle(checked, 'push')}
/>
</span>
</span>
</div>
);
};
NotificationPreferenceRow.propTypes = {
groupId: PropTypes.string.isRequired,
preferenceName: PropTypes.string.isRequired,
};
export default React.memo(NotificationPreferenceRow);

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container, Icon, Spinner } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import {
courseListStatus,
getCourse,
getPreferenceGroupIds,
notificationPreferencesStatus,
} from './data/selectors';
import { fetchCourseList, fetchCourseNotificationPreferences } from './data/thunks';
import { messages } from './messages';
import NotificationPreferenceGroup from './NotificationPreferenceGroup';
import { updateSelectedCourse } from './data/actions';
import { LOADING_STATUS, SUCCESS_STATUS } from '../constants';
const NotificationPreferences = () => {
const { courseId } = useParams();
const dispatch = useDispatch();
const intl = useIntl();
const courseStatus = useSelector(courseListStatus());
const notificationStatus = useSelector(notificationPreferencesStatus());
const course = useSelector(getCourse(courseId));
const preferenceGroups = useSelector(getPreferenceGroupIds());
const preferencesList = useMemo(() => (
preferenceGroups.map(key => (
<NotificationPreferenceGroup groupId={key} key={key} />
))
), [preferenceGroups]);
useEffect(() => {
dispatch(updateSelectedCourse(courseId));
if (courseStatus !== SUCCESS_STATUS) {
dispatch(fetchCourseList());
}
if (notificationStatus !== SUCCESS_STATUS) {
dispatch(fetchCourseNotificationPreferences(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
if (notificationStatus === LOADING_STATUS) {
return (
<div className="d-flex h-100">
<Spinner
variant="primary"
animation="border"
className="mx-auto my-auto"
style={{ width: '4rem', height: '4rem' }}
/>
</div>
);
}
return (
<Container size="md">
<h2 className="notification-heading mt-6 mb-5.5">
{intl.formatMessage(messages.notificationHeading)}
</h2>
<div>
<div className="d-flex mb-4">
<Link to="/notifications">
<Icon className="d-inline-block align-bottom ml-1" src={ArrowBack} />
</Link>
<span className="notification-course-title ml-auto mr-auto">
{course?.name}
</span>
</div>
{ preferencesList }
</div>
</Container>
);
};
export default NotificationPreferences;

View File

@@ -0,0 +1,18 @@
import { Form } from '@edx/paragon';
import React from 'react';
import PropTypes from 'prop-types';
const ToggleSwitch = ({ value, onChange }) => (
<Form.Switch checked={value} onChange={(event) => onChange(event.target.checked)} />
);
ToggleSwitch.propTypes = {
value: PropTypes.bool.isRequired,
onChange: PropTypes.func,
};
ToggleSwitch.defaultProps = {
onChange: () => null,
};
export default React.memo(ToggleSwitch);

View File

@@ -0,0 +1,58 @@
export const Actions = {
FETCHED_PREFERENCES: 'fetchedPreferences',
FETCHING_PREFERENCES: 'fetchingPreferences',
FAILED_PREFERENCES: 'failedPreferences',
FETCHING_COURSE_LIST: 'fetchingCourseList',
FETCHED_COURSE_LIST: 'fetchedCourseList',
FAILED_COURSE_LIST: 'failedCourseList',
UPDATE_SELECTED_COURSE: 'updateSelectedCourse',
UPDATE_PREFERENCE: 'updatePreference',
UPDATE_GROUP_PREFERENCE: 'updateGroupValue',
};
export const fetchNotificationPreferenceSuccess = (courseId, payload) => dispatch => (
dispatch({ type: Actions.FETCHED_PREFERENCES, courseId, payload })
);
export const fetchNotificationPreferenceFetching = () => dispatch => (
dispatch({ type: Actions.FETCHING_PREFERENCES })
);
export const fetchNotificationPreferenceFailed = () => dispatch => (
dispatch({ type: Actions.FAILED_PREFERENCES })
);
export const fetchCourseListSuccess = payload => dispatch => (
dispatch({ type: Actions.FETCHED_COURSE_LIST, payload })
);
export const fetchCourseListFetching = () => dispatch => (
dispatch({ type: Actions.FETCHING_COURSE_LIST })
);
export const fetchCourseListFailed = () => dispatch => (
dispatch({ type: Actions.FAILED_COURSE_LIST })
);
export const updateSelectedCourse = courseId => dispatch => (
dispatch({ type: Actions.UPDATE_SELECTED_COURSE, courseId })
);
export const updatePreferenceValue = (groupName, preferenceName, notificationChannel, value) => dispatch => (
dispatch({
type: Actions.UPDATE_PREFERENCE,
groupName,
preferenceName,
notificationChannel,
value,
})
);
export const updateGroupValue = (courseId, groupName, value) => dispatch => (
dispatch({
type: Actions.UPDATE_GROUP_PREFERENCE,
courseId,
groupName,
value,
})
);

View File

@@ -0,0 +1,119 @@
import { Actions } from './actions';
import {
IDLE_STATUS,
LOADING_STATUS,
SUCCESS_STATUS,
FAILURE_STATUS,
} from '../../constants';
export const defaultState = {
courses: {
status: IDLE_STATUS,
courses: [],
},
preferences: {
status: IDLE_STATUS,
selectedCourse: null,
preferences: [],
groups: [],
},
};
const notificationPreferencesReducer = (state = defaultState, action = {}) => {
const {
courseId, groupName, notificationChannel, preferenceName, value,
} = action;
switch (action.type) {
case Actions.FETCHING_COURSE_LIST:
return {
...state,
courses: {
status: LOADING_STATUS,
courses: [],
},
};
case Actions.FETCHED_COURSE_LIST:
return {
...state,
courses: {
status: SUCCESS_STATUS,
courses: action.payload,
},
};
case Actions.FAILED_COURSE_LIST:
return {
...state,
courses: {
status: FAILURE_STATUS,
courses: [],
},
};
case Actions.FETCHING_PREFERENCES:
return {
...state,
preferences: {
...state.preferences,
status: LOADING_STATUS,
preferences: {},
},
};
case Actions.FETCHED_PREFERENCES:
return {
...state,
preferences: {
...state.preferences,
status: SUCCESS_STATUS,
...action.payload,
},
};
case Actions.FAILED_PREFERENCES:
return {
...state,
preferences: {
status: FAILURE_STATUS,
preferences: {},
},
};
case Actions.UPDATE_SELECTED_COURSE:
return {
...state,
preferences: {
...state.preferences,
selectedCourse: courseId,
},
};
case Actions.UPDATE_PREFERENCE:
return {
...state,
preferences: {
...state.preferences,
preferences: state.preferences.preferences.map((element) => (
element.id === preferenceName
? { ...element, [notificationChannel]: value }
: element
)),
},
};
case Actions.UPDATE_GROUP_PREFERENCE:
return {
...state,
preferences: {
...state.preferences,
preferences: state.preferences.preferences.map((element) => (
element.groupId === groupName
? {
...element,
web: value,
email: value,
push: value,
}
: element
)),
},
};
default:
return state;
}
};
export default notificationPreferencesReducer;

View File

@@ -0,0 +1,41 @@
export const notificationPreferencesStatus = () => state => (
state.notificationPreferences.preferences.status
);
export const getPreferences = () => state => (
state.notificationPreferences?.preferences?.preferences
);
export const courseListStatus = () => state => (
state.notificationPreferences.courses.status
);
export const getCourseList = () => state => (
state.notificationPreferences.courses.courses
);
export const getCourse = courseId => state => (
getCourseList()(state).find(
element => element.id === courseId,
)
);
export const getPreferenceGroupIds = () => state => (
state.notificationPreferences.preferences.groups
);
export const getPreferenceGroup = group => state => (
getPreferences()(state).filter((preference) => (
preference.groupId === group
))
);
export const getPreferenceAttribute = (group, name) => state => (
getPreferenceGroup(group)(state).find((preference) => (
preference.id === name
))
);
export const getSelectedCourse = () => state => (
state.notificationPreferences.preferences.selectedCourse
);

View File

@@ -0,0 +1,46 @@
/* eslint-disable no-unused-vars */
// import { getConfig } from '@edx/frontend-platform';
// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function getCourseNotificationPreferences(courseId) {
// const url = `${getConfig().LMS_BASE_URL}/api/notifications/${courseId}`;
// const { data } = await getAuthenticatedHttpClient().get(url);
const data = {
discussion: {
new_post: {
web: true,
push: false,
email: false,
},
new_comment: {
web: true,
push: false,
email: false,
},
},
coursework: {
new_assignment: {
web: true,
push: false,
email: false,
},
new_grade: {
web: true,
push: false,
email: false,
},
},
};
return data;
}
export async function getCourseList() {
// const url = `${getConfig().LMS_BASE_URL}/api/notifications/${courseId}`;
// const { data } = await getAuthenticatedHttpClient().get(url);
return [
{ id: 'course-v1:edX+Supply+Demo_Course', name: 'Supply Chain Analytics' },
{ id: 'course-v1:edX+Happiness+At+Work_Course', name: 'The Foundation of Happiness At Work' },
{ id: 'course-v1:edX+Empathy+At+Work_Course', name: 'Empathy and Emotional Intelligence At Work' },
];
}

View File

@@ -0,0 +1,60 @@
import { camelCaseObject } from '@edx/frontend-platform';
import {
fetchCourseListSuccess,
fetchCourseListFetching,
fetchCourseListFailed,
fetchNotificationPreferenceFailed,
fetchNotificationPreferenceFetching,
fetchNotificationPreferenceSuccess,
} from './actions';
import {
getCourseList,
getCourseNotificationPreferences,
} from './service';
const normalizePreferences = (preferences) => {
const groups = Object.keys(preferences);
const preferenceList = groups.map(groupId => {
const preferencesKeys = Object.keys(preferences[groupId]);
const flatPreferences = preferencesKeys.map(preferenceId => (
{
id: preferenceId,
groupId,
web: preferences?.[groupId]?.[preferenceId].web,
push: preferences?.[groupId]?.[preferenceId].push,
mobile: preferences?.[groupId]?.[preferenceId].mobile,
}
));
return flatPreferences;
}).flat();
const normalizedPreferences = {
groups,
preferences: preferenceList,
};
return normalizedPreferences;
};
export const fetchCourseList = () => (
async (dispatch) => {
try {
dispatch(fetchCourseListFetching());
const data = await getCourseList();
dispatch(fetchCourseListSuccess(data));
} catch (errors) {
dispatch(fetchCourseListFailed());
}
}
);
export const fetchCourseNotificationPreferences = (courseId) => (
async (dispatch) => {
try {
dispatch(fetchNotificationPreferenceFetching());
const data = await getCourseNotificationPreferences(courseId);
const normalizedData = normalizePreferences(camelCaseObject(data));
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
} catch (errors) {
dispatch(fetchNotificationPreferenceFailed());
}
}
);

View File

@@ -0,0 +1,52 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
// eslint-disable-next-line import/prefer-default-export
export const messages = defineMessages({
notificationHeading: {
id: 'notification.preference.heading',
defaultMessage: 'Notifications',
description: 'Notification title',
},
notificationGroupTitle: {
id: 'notification.preference.group.title',
defaultMessage: `{
key, select,
discussion {Discussions}
coursework {Course Work}
other {{key}}
}`,
description: 'Display text for Notification Types',
},
notificationTitle: {
id: 'notification.preference.title',
defaultMessage: `{
text, select,
newPost {New Post}
newComment {New Comment}
newAssignment {New Assignment}
newGrade {New Grade}
other {{text}}
}`,
description: 'Display text for Notification Types',
},
notificationHelpType: {
id: 'notification.preference.help.type',
defaultMessage: 'Type',
description: 'Display text for type',
},
notificationHelpWeb: {
id: 'notification.preference.help.web',
defaultMessage: 'Web',
description: 'Display text for web',
},
notificationHelpEmail: {
id: 'notification.preference.help.email',
defaultMessage: 'Email',
description: 'Display text for email',
},
notificationHelpPush: {
id: 'notification.preference.help.push',
defaultMessage: 'Push',
description: 'Display text for push',
},
});