diff --git a/src/account-settings/AccountSettingsPage.jsx b/src/account-settings/AccountSettingsPage.jsx index c3f3c32..28cb41d 100644 --- a/src/account-settings/AccountSettingsPage.jsx +++ b/src/account-settings/AccountSettingsPage.jsx @@ -819,12 +819,12 @@ class AccountSettingsPage extends React.Component {
-
+
-
+
{loading ? this.renderLoading() : null} {loaded ? this.renderContent() : null} {loadingError ? this.renderError() : null} diff --git a/src/account-settings/AccountSettingsPage.messages.jsx b/src/account-settings/AccountSettingsPage.messages.jsx index 1514034..34d2f98 100644 --- a/src/account-settings/AccountSettingsPage.messages.jsx +++ b/src/account-settings/AccountSettingsPage.messages.jsx @@ -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; diff --git a/src/account-settings/JumpNav.jsx b/src/account-settings/JumpNav.jsx index 2f76446..a671bfe 100644 --- a/src/account-settings/JumpNav.jsx +++ b/src/account-settings/JumpNav.jsx @@ -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 ( -
+
+ {showNotificationMenu + && ( + <> +
+ +
  • + + {intl.formatMessage(messages['notification.preferences.notifications.label'])} + + +
  • +
    + + )}
    ); }; diff --git a/src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap b/src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap index 1042288..2b01f27 100644 --- a/src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap +++ b/src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap @@ -2,7 +2,7 @@ exports[`JumpNav should not render Optional Information link 1`] = `
        combineReducers({ [accountSettingsStoreName]: accountSettingsReducer, + notificationPreferences: notificationPreferencesReducer, }); export default createRootReducer; diff --git a/src/index.jsx b/src/index.jsx index 67cc0ba..3874c72 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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( @@ -32,6 +35,12 @@ subscribe(APP_READY, () => {
        + {allowNotificationRoutes && ( + <> + + + + )} diff --git a/src/index.scss b/src/index.scss index 02aec83..9e590df 100755 --- a/src/index.scss +++ b/src/index.scss @@ -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; +} diff --git a/src/notification-preferences/NotificationCourses.jsx b/src/notification-preferences/NotificationCourses.jsx new file mode 100644 index 0000000..4697b90 --- /dev/null +++ b/src/notification-preferences/NotificationCourses.jsx @@ -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 ( +
        + +
        + ); + } + return ( + +

        + {intl.formatMessage(messages.notificationHeading)} +

        +
        + { + coursesList.map(course => ( + +
        + + {course.name} + + + + +
        + + )) + } +
        +
        + ); +}; + +NotificationCourses.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(NotificationCourses); diff --git a/src/notification-preferences/NotificationPreferenceGroup.jsx b/src/notification-preferences/NotificationPreferenceGroup.jsx new file mode 100644 index 0000000..c7a0929 --- /dev/null +++ b/src/notification-preferences/NotificationPreferenceGroup.jsx @@ -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 => ( + + ))), [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 ( + + +
        + + {intl.formatMessage(messages.notificationGroupTitle, { key: groupId })} + + + + +
        +
        +
        + +
        + {intl.formatMessage(messages.notificationHelpType)} + + {intl.formatMessage(messages.notificationHelpWeb)} + {intl.formatMessage(messages.notificationHelpEmail)} + {intl.formatMessage(messages.notificationHelpPush)} + +
        +
        + { preferences } +
        +
        +
        + ); +}; + +NotificationPreferenceGroup.propTypes = { + groupId: PropTypes.string.isRequired, +}; + +export default React.memo(NotificationPreferenceGroup); diff --git a/src/notification-preferences/NotificationPreferenceRow.jsx b/src/notification-preferences/NotificationPreferenceRow.jsx new file mode 100644 index 0000000..fd6eda7 --- /dev/null +++ b/src/notification-preferences/NotificationPreferenceRow.jsx @@ -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 ( +
        + + {intl.formatMessage(messages.notificationTitle, { text: preferenceName })} + + + + onToggle(checked, 'web')} + /> + + + onToggle(checked, 'email')} + /> + + + onToggle(checked, 'push')} + /> + + +
        + ); +}; + +NotificationPreferenceRow.propTypes = { + groupId: PropTypes.string.isRequired, + preferenceName: PropTypes.string.isRequired, +}; + +export default React.memo(NotificationPreferenceRow); diff --git a/src/notification-preferences/NotificationPreferences.jsx b/src/notification-preferences/NotificationPreferences.jsx new file mode 100644 index 0000000..6341b66 --- /dev/null +++ b/src/notification-preferences/NotificationPreferences.jsx @@ -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 => ( + + )) + ), [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 ( +
        + +
        + ); + } + return ( + +

        + {intl.formatMessage(messages.notificationHeading)} +

        +
        +
        + + + + + {course?.name} + +
        + { preferencesList } +
        +
        + ); +}; + +export default NotificationPreferences; diff --git a/src/notification-preferences/ToggleSwitch.jsx b/src/notification-preferences/ToggleSwitch.jsx new file mode 100644 index 0000000..373e0ba --- /dev/null +++ b/src/notification-preferences/ToggleSwitch.jsx @@ -0,0 +1,18 @@ +import { Form } from '@edx/paragon'; +import React from 'react'; +import PropTypes from 'prop-types'; + +const ToggleSwitch = ({ value, onChange }) => ( + onChange(event.target.checked)} /> +); + +ToggleSwitch.propTypes = { + value: PropTypes.bool.isRequired, + onChange: PropTypes.func, +}; + +ToggleSwitch.defaultProps = { + onChange: () => null, +}; + +export default React.memo(ToggleSwitch); diff --git a/src/notification-preferences/data/actions.js b/src/notification-preferences/data/actions.js new file mode 100644 index 0000000..7ccbba8 --- /dev/null +++ b/src/notification-preferences/data/actions.js @@ -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, + }) +); diff --git a/src/notification-preferences/data/reducers.js b/src/notification-preferences/data/reducers.js new file mode 100644 index 0000000..da0ac8b --- /dev/null +++ b/src/notification-preferences/data/reducers.js @@ -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; diff --git a/src/notification-preferences/data/selectors.js b/src/notification-preferences/data/selectors.js new file mode 100644 index 0000000..0f8b270 --- /dev/null +++ b/src/notification-preferences/data/selectors.js @@ -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 +); diff --git a/src/notification-preferences/data/service.js b/src/notification-preferences/data/service.js new file mode 100644 index 0000000..e53ba9f --- /dev/null +++ b/src/notification-preferences/data/service.js @@ -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' }, + ]; +} diff --git a/src/notification-preferences/data/thunks.js b/src/notification-preferences/data/thunks.js new file mode 100644 index 0000000..8640981 --- /dev/null +++ b/src/notification-preferences/data/thunks.js @@ -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()); + } + } +); diff --git a/src/notification-preferences/messages.js b/src/notification-preferences/messages.js new file mode 100644 index 0000000..d0dbf4e --- /dev/null +++ b/src/notification-preferences/messages.js @@ -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', + }, +});