diff --git a/src/hooks.js b/src/hooks.js index 6d3feca3e..cbe24ddfe 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; +import { breakpoints, useWindowSize } from '@openedx/paragon'; import { IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS, FAILURE_STATUS, @@ -66,3 +67,8 @@ export function useFeedbackWrapper() { } }, []); } + +export function useIsOnMobile() { + const windowSize = useWindowSize(); + return windowSize.width <= breakpoints.small.minWidth; +} diff --git a/src/index.scss b/src/index.scss index d0ad5390f..488cec0a4 100755 --- a/src/index.scss +++ b/src/index.scss @@ -84,11 +84,39 @@ $fa-font-path: "~font-awesome/fonts"; color: #707070; } + .preference-app { + font-size: 18px; + font-weight: 700; + } + + .column-padding{ + padding-left: 32px; + padding-right: 32px; + } + .notification-course-title { line-height: 28px; font-weight: 700; font-size: 18px; } + + .font-size-14{ + font-size: 14px !important; + } + + .cadence-button { + width: 134px; + height: 36px; + font-weight: 500; + } + + .line-height-36{ + line-height: 36px; + } + + .h-4\.5 { + height: 36px; + } } .usabilla_live_button_container { @@ -102,3 +130,15 @@ $fa-font-path: "~font-awesome/fonts"; transform: rotate(270deg) !important; } } + +@media screen and (max-width: 425px) { + .column-padding{ + padding-left: 16px; + padding-right: 16px; + } + + .margin-bottom-32{ + margin-bottom: 32px !important; + } +} + diff --git a/src/notification-preferences/EmailCadences.jsx b/src/notification-preferences/EmailCadences.jsx new file mode 100644 index 000000000..45043ec41 --- /dev/null +++ b/src/notification-preferences/EmailCadences.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Dropdown, ModalPopup, Button, useToggle, +} from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import messages from './messages'; +import { EMAIL_CADENCE } from './data/constants'; + +const EmailCadences = ({ + email, onToggle, emailCadence, notificationType, +}) => { + const intl = useIntl(); + const [isOpen, open, close] = useToggle(false); + const [target, setTarget] = useState(null); + + return ( + <> + + +
+ {Object.values(EMAIL_CADENCE).map((cadence) => ( + onToggle(event, notificationType)} + > + {intl.formatMessage(messages.emailCadence, { text: cadence })} + + ))} +
+
+ + ); +}; + +EmailCadences.propTypes = { + email: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE)).isRequired, + notificationType: PropTypes.string.isRequired, +}; + +export default React.memo(EmailCadences); diff --git a/src/notification-preferences/NotificationPreferenceApp.jsx b/src/notification-preferences/NotificationPreferenceApp.jsx index 1ab67b030..29ac6fb18 100644 --- a/src/notification-preferences/NotificationPreferenceApp.jsx +++ b/src/notification-preferences/NotificationPreferenceApp.jsx @@ -1,65 +1,49 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Collapsible, NavItem } from '@openedx/paragon'; +import { Collapsible } from '@openedx/paragon'; import classNames from 'classnames'; import messages from './messages'; import ToggleSwitch from './ToggleSwitch'; import { selectPreferenceAppToggleValue, - selectNonEditablePreferences, - selectPreferencesOfApp, selectSelectedCourseId, selectUpdatePreferencesStatus, } from './data/selectors'; -import NotificationPreferenceRow from './NotificationPreferenceRow'; -import { updateAppPreferenceToggle, updateChannelPreferenceToggle } from './data/thunks'; +import NotificationPreferenceColumn from './NotificationPreferenceColumn'; +import { updateAppPreferenceToggle } from './data/thunks'; import { LOADING_STATUS } from '../constants'; -import NOTIFICATION_CHANNELS from './data/constants'; +import { NOTIFICATION_CHANNELS } from './data/constants'; +import NotificationTypes from './NotificationTypes'; +import { useIsOnMobile } from '../hooks'; const NotificationPreferenceApp = ({ appId }) => { const dispatch = useDispatch(); const intl = useIntl(); const courseId = useSelector(selectSelectedCourseId()); - const appPreferences = useSelector(selectPreferencesOfApp(appId)); const appToggle = useSelector(selectPreferenceAppToggleValue(appId)); const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus()); - const nonEditable = useSelector(selectNonEditablePreferences(appId)); - - const onChannelToggle = useCallback((event) => { - const { id: notificationChannel } = event.target; - const isPreferenceNonEditable = (preference) => nonEditable?.[preference.id]?.includes(notificationChannel); - - const hasActivePreferences = appPreferences.some( - (preference) => preference[notificationChannel] && !isPreferenceNonEditable(preference), - ); - - dispatch(updateChannelPreferenceToggle(courseId, appId, notificationChannel, !hasActivePreferences)); - }, [appId, appPreferences, courseId, dispatch, nonEditable]); - - const preferences = useMemo(() => ( - appPreferences.map(preference => ( - - ))), [appId, appPreferences]); + const mobileView = useIsOnMobile(); const onChangeAppSettings = useCallback((event) => { dispatch(updateAppPreferenceToggle(courseId, appId, event.target.checked)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [appId]); + if (!courseId) { return null; } return ( - +
- + {intl.formatMessage(messages.notificationAppTitle, { key: appId })} @@ -71,33 +55,20 @@ const NotificationPreferenceApp = ({ appId }) => { />
-
+ {!mobileView &&
}
-
- {intl.formatMessage(messages.typeLabel)} - - {NOTIFICATION_CHANNELS.map((channel) => ( - - {intl.formatMessage(messages.notificationChannel, { text: channel })} - +
+ + {!mobileView && ( +
+ {Object.values(NOTIFICATION_CHANNELS).map((channel) => ( + ))} - -
-
- { preferences } +
+ )}
+ {mobileView &&
} ); diff --git a/src/notification-preferences/NotificationPreferenceColumn.jsx b/src/notification-preferences/NotificationPreferenceColumn.jsx new file mode 100644 index 000000000..5178fce8f --- /dev/null +++ b/src/notification-preferences/NotificationPreferenceColumn.jsx @@ -0,0 +1,124 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { NavItem } from '@openedx/paragon'; +import messages from './messages'; +import ToggleSwitch from './ToggleSwitch'; +import { + selectNonEditablePreferences, + selectSelectedCourseId, + selectUpdatePreferencesStatus, + selectPreferencesOfApp, +} from './data/selectors'; +import { updatePreferenceToggle, updateChannelPreferenceToggle } from './data/thunks'; +import { LOADING_STATUS } from '../constants'; +import EmailCadences from './EmailCadences'; +import { useIsOnMobile } from '../hooks'; + +const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const courseId = useSelector(selectSelectedCourseId()); + const appPreferences = useSelector(selectPreferencesOfApp(appId)); + const nonEditable = useSelector(selectNonEditablePreferences(appId)); + const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus()); + const mobileView = useIsOnMobile(); + + const onChannelToggle = useCallback((event) => { + const { id: notificationChannel } = event.target; + const isPreferenceNonEditable = (preference) => nonEditable?.[preference.id]?.includes(notificationChannel); + + const hasActivePreferences = appPreferences.some( + (preference) => preference[notificationChannel] && !isPreferenceNonEditable(preference), + ); + + dispatch(updateChannelPreferenceToggle(courseId, appId, notificationChannel, !hasActivePreferences)); + }, [appId, appPreferences, courseId, dispatch, nonEditable]); + + const onToggle = useCallback((event, notificationType) => { + const { name: notificationChannel } = event.target; + const value = notificationChannel === 'email_cadence' ? event.target.innerText : event.target.checked; + + dispatch(updatePreferenceToggle( + courseId, + appId, + notificationType, + notificationChannel, + value, + )); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appId]); + + const renderPreference = (preference) => ( +
+ onToggle(event, preference.id)} + disabled={nonEditable?.[preference.id]?.includes(channel) || updatePreferencesStatus === LOADING_STATUS} + id={`${preference.id}-${channel}`} + className="my-1" + /> + {channel === 'email' && ( + + )} +
+ ); + + return ( +
+ + {intl.formatMessage(messages.notificationChannel, { text: channel })} + + {appPreference + ? renderPreference(appPreference) + : appPreferences.map((preference) => (renderPreference(preference)))} +
+ ); +}; + +NotificationPreferenceColumn.propTypes = { + appId: PropTypes.string.isRequired, + channel: PropTypes.string.isRequired, + appPreference: PropTypes.shape({ + id: PropTypes.string, + emailCadence: PropTypes.string, + appId: PropTypes.string, + info: PropTypes.string, + email: PropTypes.bool, + push: PropTypes.bool, + web: PropTypes.bool, + }), +}; + +NotificationPreferenceColumn.defaultProps = { + appPreference: null, +}; + +export default React.memo(NotificationPreferenceColumn); diff --git a/src/notification-preferences/NotificationPreferenceRow.jsx b/src/notification-preferences/NotificationPreferenceRow.jsx deleted file mode 100644 index 8541a9e0c..000000000 --- a/src/notification-preferences/NotificationPreferenceRow.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useCallback } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon'; -import { InfoOutline } from '@openedx/paragon/icons'; -import messages from './messages'; -import ToggleSwitch from './ToggleSwitch'; -import { - selectPreference, - selectPreferenceNonEditableChannels, - selectSelectedCourseId, - selectUpdatePreferencesStatus, -} from './data/selectors'; -import NOTIFICATION_CHANNELS from './data/constants'; -import { updatePreferenceToggle } from './data/thunks'; -import { LOADING_STATUS } from '../constants'; - -const NotificationPreferenceRow = ({ appId, preferenceName }) => { - const dispatch = useDispatch(); - const intl = useIntl(); - const courseId = useSelector(selectSelectedCourseId()); - const preference = useSelector(selectPreference(appId, preferenceName)); - const nonEditable = useSelector(selectPreferenceNonEditableChannels(appId, preferenceName)); - const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus()); - - const onToggle = useCallback((event) => { - const { - checked, - name: notificationChannel, - } = event.target; - dispatch(updatePreferenceToggle( - courseId, - appId, - preferenceName, - notificationChannel, - checked, - )); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appId, preferenceName]); - - const tooltipId = `${preferenceName}-tooltip`; - return ( -
-
- {intl.formatMessage(messages.notificationTitle, { text: preferenceName })} - {preference.info !== '' && ( - - {preference.info} - - )} - > - - - - - )} -
-
- {NOTIFICATION_CHANNELS.map((channel) => ( -
- -
- ))} -
-
- ); -}; - -NotificationPreferenceRow.propTypes = { - appId: 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 index 07cc03d6c..dcdd531bf 100644 --- a/src/notification-preferences/NotificationPreferences.jsx +++ b/src/notification-preferences/NotificationPreferences.jsx @@ -61,7 +61,7 @@ const NotificationPreferences = () => {

{intl.formatMessage(messages.notificationHeading)}

-
+
{intl.formatMessage(messages.notificationPreferenceGuideBody)} { + const intl = useIntl(); + const preferences = useSelector(selectPreferencesOfApp(appId)); + const mobileView = useIsOnMobile(); + + return ( +
+ {!mobileView && {intl.formatMessage(messages.typeLabel)}} + {preferences.map(preference => ( + <> +
+ {intl.formatMessage(messages.notificationTitle, { text: preference.id })} + {preference.info !== '' && ( + + {preference.info} + + )} + > + + + + + )} +
+ {mobileView && ( +
+ {Object.values(NOTIFICATION_CHANNELS).map((channel) => ( + + ))} +
+ )} + + ))} +
+ ); +}; + +NotificationTypes.propTypes = { + appId: PropTypes.string.isRequired, +}; + +export default React.memo(NotificationTypes); diff --git a/src/notification-preferences/ToggleSwitch.jsx b/src/notification-preferences/ToggleSwitch.jsx index 91c2c995d..7e6fc28a2 100644 --- a/src/notification-preferences/ToggleSwitch.jsx +++ b/src/notification-preferences/ToggleSwitch.jsx @@ -8,6 +8,7 @@ const ToggleSwitch = ({ disabled, onChange, id, + className, }) => ( ); @@ -24,12 +26,14 @@ ToggleSwitch.propTypes = { disabled: PropTypes.bool, onChange: PropTypes.func, id: PropTypes.string, + className: PropTypes.string, }; ToggleSwitch.defaultProps = { onChange: () => null, disabled: false, id: '', + className: '', }; export default React.memo(ToggleSwitch); diff --git a/src/notification-preferences/data/constants.js b/src/notification-preferences/data/constants.js index d3e2e5cf1..0543f2e63 100644 --- a/src/notification-preferences/data/constants.js +++ b/src/notification-preferences/data/constants.js @@ -1,3 +1,10 @@ -const NOTIFICATION_CHANNELS = ['web']; +export const NOTIFICATION_CHANNELS = { + WEB: 'web', + EMAIL: 'email', +}; -export default NOTIFICATION_CHANNELS; +export const EMAIL_CADENCE = { + DAILY: 'Daily', + WEEKLY: 'Weekly', + IMMEDIATELY: 'Immediately', +}; diff --git a/src/notification-preferences/data/thunks.js b/src/notification-preferences/data/thunks.js index 222201b52..2e770937f 100644 --- a/src/notification-preferences/data/thunks.js +++ b/src/notification-preferences/data/thunks.js @@ -1,4 +1,5 @@ import { camelCaseObject } from '@edx/frontend-platform'; +import { EMAIL_CADENCE } from './constants'; import { fetchCourseListSuccess, fetchCourseListFetching, @@ -58,6 +59,7 @@ const normalizePreferences = (responseData) => { push: preferences[appId].notificationTypes[preferenceId].push, email: preferences[appId].notificationTypes[preferenceId].email, info: preferences[appId].notificationTypes[preferenceId].info || '', + emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence || EMAIL_CADENCE.DAILY, } )); nonEditable[appId] = preferences[appId].nonEditable; diff --git a/src/notification-preferences/messages.js b/src/notification-preferences/messages.js index d1b8fbb15..3f0abfd86 100644 --- a/src/notification-preferences/messages.js +++ b/src/notification-preferences/messages.js @@ -41,6 +41,17 @@ const messages = defineMessages({ }`, description: 'Display text for Notification Channel', }, + emailCadence: { + id: 'notification.preference.emailCadence', + defaultMessage: `{ + text, select, + Daily {Daily} + Weekly {Weekly} + Immediately {Immediately} + other {{text}} + }`, + description: 'Display text for Email Cadence', + }, typeLabel: { id: 'notification.preference.type.label', defaultMessage: 'Type',