From 553e9f8ead9e00205c290a23e20acea8c2d7a407 Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Tue, 31 Aug 2021 22:21:29 +0200 Subject: [PATCH] Rework notifications (#1465) * Rework notifications - dispatchUINotification returns a promise that contains the notification id - notifications use i18n instead of plain text Signed-off-by: Tilman Vatteroth * Reformat code Signed-off-by: Tilman Vatteroth --- public/locales/en.json | 4 + .../editor-pane/autocompletion/code-block.ts | 10 +-- .../sidebar/pin-note-sidebar-entry.tsx | 6 +- .../editor-page/use-notification-test.tsx | 22 +++-- .../history-content/history-content.tsx | 34 +++----- src/components/history-page/history-page.tsx | 6 +- .../history-toolbar/clear-history-button.tsx | 6 +- .../history-toolbar/history-toolbar.tsx | 8 +- .../history-toolbar/import-history-button.tsx | 6 +- .../notifications/ui-notification-toast.tsx | 30 ++++--- src/redux/ui-notifications/methods.ts | 85 ++++++++++++------- src/redux/ui-notifications/reducers.ts | 21 ++++- src/redux/ui-notifications/types.ts | 21 +++-- 13 files changed, 154 insertions(+), 105 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 545d3be88..73a7e0cf5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -3,6 +3,10 @@ "slogan": "The best platform to write and share markdown.", "title": "Collaborative markdown notes" }, + "notificationTest": { + "title": "Test", + "content": "It works!" + }, "renderer": { "highlightCode": { "copyCode": "Copy code to clipboard" diff --git a/src/components/editor-page/editor-pane/autocompletion/code-block.ts b/src/components/editor-page/editor-pane/autocompletion/code-block.ts index b1a3aeffa..e2692ffbc 100644 --- a/src/components/editor-page/editor-pane/autocompletion/code-block.ts +++ b/src/components/editor-page/editor-pane/autocompletion/code-block.ts @@ -6,8 +6,7 @@ import { Editor, Hint, Hints, Pos } from 'codemirror' import { findWordAtCursor, generateHintListByPrefix, Hinter } from './index' -import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../../../redux/ui-notifications/methods' -import i18n from 'i18next' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' type highlightJsImport = typeof import('../../../common/hljs/hljs') @@ -22,12 +21,7 @@ const loadHighlightJs = async (): Promise => { try { return await import('../../../common/hljs/hljs') } catch (error) { - dispatchUiNotification( - i18n.t('common.errorOccurred'), - i18n.t('common.errorWhileLoadingLibrary', { name: 'highlight.js' }), - DEFAULT_DURATION_IN_SECONDS, - 'exclamation-circle' - ) + showErrorNotification('common.errorWhileLoadingLibrary', { name: 'highlight.js' })(error as Error) console.error("can't load highlight js", error) return null } diff --git a/src/components/editor-page/sidebar/pin-note-sidebar-entry.tsx b/src/components/editor-page/sidebar/pin-note-sidebar-entry.tsx index bd2f0f0ef..110ae667c 100644 --- a/src/components/editor-page/sidebar/pin-note-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/pin-note-sidebar-entry.tsx @@ -15,7 +15,7 @@ import { showErrorNotification } from '../../../redux/ui-notifications/methods' import { useApplicationState } from '../../../hooks/common/use-application-state' export const PinNoteSidebarEntry: React.FC = ({ className, hide }) => { - const { t } = useTranslation() + useTranslation() const { id } = useParams() const history = useApplicationState((state) => state.history) @@ -28,8 +28,8 @@ export const PinNoteSidebarEntry: React.FC = ({ class }, [id, history]) const onPinClicked = useCallback(() => { - toggleHistoryEntryPinning(id).catch(showErrorNotification(t('landing.history.error.updateEntry.text'))) - }, [id, t]) + toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text')) + }, [id]) return ( { useEffect(() => { if (window.localStorage.getItem(localStorageKey)) { return } console.debug('[Notifications] Dispatched test notification') - dispatchUiNotification('Notification-Test!', 'It Works!', DEFAULT_DURATION_IN_SECONDS, 'info-circle', [ - { - label: "Don't show again", - onClick: () => { - window.localStorage.setItem(localStorageKey, '1') + void dispatchUiNotification('notificationTest.title', 'notificationTest.content', { + icon: 'info-circle', + buttons: [ + { + label: "Don't show again", + onClick: () => { + window.localStorage.setItem(localStorageKey, '1') + } } - } - ]) + ] + }) }, []) } diff --git a/src/components/history-page/history-content/history-content.tsx b/src/components/history-page/history-content/history-content.tsx index 96117498d..b670a054f 100644 --- a/src/components/history-page/history-content/history-content.tsx +++ b/src/components/history-page/history-content/history-content.tsx @@ -40,33 +40,23 @@ export interface HistoryEntriesProps { } export const HistoryContent: React.FC = ({ viewState, entries }) => { - const { t } = useTranslation() - + useTranslation() const [pageIndex, setPageIndex] = useState(0) const [lastPageIndex, setLastPageIndex] = useState(0) - const onPinClick = useCallback( - (noteId: string) => { - toggleHistoryEntryPinning(noteId).catch(showErrorNotification(t('landing.history.error.updateEntry.text'))) - }, - [t] - ) + const onPinClick = useCallback((noteId: string) => { + toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text')) + }, []) - const onDeleteClick = useCallback( - (noteId: string) => { - deleteNote(noteId) - .then(() => removeHistoryEntry(noteId)) - .catch(showErrorNotification(t('landing.history.error.deleteNote.text'))) - }, - [t] - ) + const onDeleteClick = useCallback((noteId: string) => { + deleteNote(noteId) + .then(() => removeHistoryEntry(noteId)) + .catch(showErrorNotification('landing.history.error.deleteNote.text')) + }, []) - const onRemoveClick = useCallback( - (noteId: string) => { - removeHistoryEntry(noteId).catch(showErrorNotification(t('landing.history.error.deleteEntry.text'))) - }, - [t] - ) + const onRemoveClick = useCallback((noteId: string) => { + removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text')) + }, []) if (entries.length === 0) { return ( diff --git a/src/components/history-page/history-page.tsx b/src/components/history-page/history-page.tsx index 41a680bde..d614130bc 100644 --- a/src/components/history-page/history-page.tsx +++ b/src/components/history-page/history-page.tsx @@ -16,7 +16,7 @@ import { showErrorNotification } from '../../redux/ui-notifications/methods' import { useApplicationState } from '../../hooks/common/use-application-state' export const HistoryPage: React.FC = () => { - const { t } = useTranslation() + useTranslation() const allEntries = useApplicationState((state) => state.history) const [toolbarState, setToolbarState] = useState(initToolbarState) @@ -27,8 +27,8 @@ export const HistoryPage: React.FC = () => { ) useEffect(() => { - refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) - }, [t]) + refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text')) + }, []) return ( diff --git a/src/components/history-page/history-toolbar/clear-history-button.tsx b/src/components/history-page/history-toolbar/clear-history-button.tsx index db8fad729..c2c29b6f4 100644 --- a/src/components/history-page/history-toolbar/clear-history-button.tsx +++ b/src/components/history-page/history-toolbar/clear-history-button.tsx @@ -21,11 +21,11 @@ export const ClearHistoryButton: React.FC = () => { const onConfirm = useCallback(() => { deleteAllHistoryEntries().catch((error) => { - showErrorNotification(t('landing.history.error.deleteEntry.text'))(error) - refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) + showErrorNotification('landing.history.error.deleteEntry.text')(error) + refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text')) }) handleClose() - }, [t]) + }, []) return ( diff --git a/src/components/history-page/history-toolbar/history-toolbar.tsx b/src/components/history-page/history-toolbar/history-toolbar.tsx index 3630e6761..ae091cf03 100644 --- a/src/components/history-page/history-toolbar/history-toolbar.tsx +++ b/src/components/history-page/history-toolbar/history-toolbar.tsx @@ -116,8 +116,8 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange ) const refreshHistory = useCallback(() => { - refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) - }, [t]) + refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text')) + }, []) const onUploadAllToRemote = useCallback(() => { if (!userExists) { @@ -128,7 +128,7 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange .map((entry) => entry.identifier) historyEntries.forEach((entry) => (entry.origin = HistoryEntryOrigin.REMOTE)) importHistoryEntries(historyEntries).catch((error) => { - showErrorNotification(t('landing.history.error.setHistory.text'))(error) + showErrorNotification('landing.history.error.setHistory.text')(error) historyEntries.forEach((entry) => { if (localEntries.includes(entry.identifier)) { entry.origin = HistoryEntryOrigin.LOCAL @@ -137,7 +137,7 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange setHistoryEntries(historyEntries) refreshHistory() }) - }, [userExists, historyEntries, t, refreshHistory]) + }, [userExists, historyEntries, refreshHistory]) useEffect(() => { const newState: HistoryToolbarState = { diff --git a/src/components/history-page/history-toolbar/import-history-button.tsx b/src/components/history-page/history-toolbar/import-history-button.tsx index e561d34ae..5371490f8 100644 --- a/src/components/history-page/history-toolbar/import-history-button.tsx +++ b/src/components/history-page/history-toolbar/import-history-button.tsx @@ -42,11 +42,11 @@ export const ImportHistoryButton: React.FC = () => { (entries: HistoryEntry[]): void => { entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL)) importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error) => { - showErrorNotification(t('landing.history.error.setHistory.text'))(error) - refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) + showErrorNotification('landing.history.error.setHistory.text')(error) + refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text')) }) }, - [historyState, userExists, t] + [historyState, userExists] ) const handleUpload = (event: React.ChangeEvent) => { diff --git a/src/components/notifications/ui-notification-toast.tsx b/src/components/notifications/ui-notification-toast.tsx index e09b5ebb4..c4467eee8 100644 --- a/src/components/notifications/ui-notification-toast.tsx +++ b/src/components/notifications/ui-notification-toast.tsx @@ -11,6 +11,7 @@ import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon' import { ShowIf } from '../common/show-if/show-if' import { IconName } from '../common/fork-awesome/types' import { dismissUiNotification } from '../../redux/ui-notifications/methods' +import { Trans, useTranslation } from 'react-i18next' const STEPS_PER_SECOND = 10 @@ -19,8 +20,10 @@ export interface UiNotificationProps extends UiNotification { } export const UiNotificationToast: React.FC = ({ - title, - content, + titleI18nKey, + contentI18nKey, + titleI18nOptions, + contentI18nOptions, date, icon, dismissed, @@ -28,6 +31,7 @@ export const UiNotificationToast: React.FC = ({ durationInSecond, buttons }) => { + const { t } = useTranslation() const [eta, setEta] = useState() const interval = useRef(undefined) @@ -89,15 +93,17 @@ export const UiNotificationToast: React.FC = ({ ) const contentDom = useMemo(() => { - return content.split('\n').map((value, lineNumber) => { - return ( - - {value} -
-
- ) - }) - }, [content]) + return t(contentI18nKey, contentI18nOptions) + .split('\n') + .map((value, lineNumber) => { + return ( + + {value} +
+
+ ) + }) + }, [contentI18nKey, contentI18nOptions, t]) return ( @@ -106,7 +112,7 @@ export const UiNotificationToast: React.FC = ({ - {title} + {date.toRelative({ style: 'short' })} diff --git a/src/redux/ui-notifications/methods.ts b/src/redux/ui-notifications/methods.ts index 139f72573..b543b8057 100644 --- a/src/redux/ui-notifications/methods.ts +++ b/src/redux/ui-notifications/methods.ts @@ -4,40 +4,56 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import i18n from 'i18next' +import i18n, { TOptions } from 'i18next' import { store } from '../index' -import { - DismissUiNotificationAction, - DispatchUiNotificationAction, - UiNotificationActionType, - UiNotificationButton -} from './types' +import { DismissUiNotificationAction, DispatchOptions, UiNotificationActionType } from './types' import { DateTime } from 'luxon' -import { IconName } from '../../components/common/fork-awesome/types' export const DEFAULT_DURATION_IN_SECONDS = 10 -export const dispatchUiNotification = ( - title: string, - content: string, - durationInSecond = DEFAULT_DURATION_IN_SECONDS, - icon?: IconName, - buttons?: UiNotificationButton[] -): void => { - store.dispatch({ - type: UiNotificationActionType.DISPATCH_NOTIFICATION, - notification: { - title, - content, - date: DateTime.now(), - dismissed: false, - icon, - durationInSecond, - buttons: buttons - } - } as DispatchUiNotificationAction) +/** + * Dispatches a new UI Notification into the global application state. + * + * @param titleI18nKey I18n key used to show the localized title + * @param contentI18nKey I18n key used to show the localized content + * @param icon The icon in the upper left corner + * @param durationInSecond Show duration of the notification. If omitted then a {@link DEFAULT_DURATION_IN_SECONDS default value} will be used. + * @param buttons A array of actions that are shown in the notification + * @param contentI18nOptions Options to configure the translation of the title. (e.g. variables) + * @param titleI18nOptions Options to configure the translation of the content. (e.g. variables) + * @return a promise that resolves as soon as the notification id available. + */ +export const dispatchUiNotification = async ( + titleI18nKey: string, + contentI18nKey: string, + { icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial +): Promise => { + return new Promise((resolve) => { + store.dispatch({ + type: UiNotificationActionType.DISPATCH_NOTIFICATION, + notificationIdCallback: (notificationId: number) => { + resolve(notificationId) + }, + notification: { + titleI18nKey, + contentI18nKey, + date: DateTime.now(), + dismissed: false, + titleI18nOptions: titleI18nOptions ?? {}, + contentI18nOptions: contentI18nOptions ?? {}, + durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS, + buttons: buttons ?? [], + icon: icon + } + }) + }) } +/** + * Dismisses a notification. It won't be removed from the global application state but hidden. + * + * @param notificationId The id of the notification to dismissed. Can be obtained from the returned promise of {@link dispatchUiNotification} + */ export const dismissUiNotification = (notificationId: number): void => { store.dispatch({ type: UiNotificationActionType.DISMISS_NOTIFICATION, @@ -45,9 +61,18 @@ export const dismissUiNotification = (notificationId: number): void => { } as DismissUiNotificationAction) } +/** + * Dispatches an notification that is specialized for errors. + * + * @param messageI18nKey i18n key for the message + * @param messageI18nOptions i18n options for the message + */ export const showErrorNotification = - (message: string) => + (messageI18nKey: string, messageI18nOptions?: TOptions | string) => (error: Error): void => { - console.error(message, error) - dispatchUiNotification(i18n.t('common.errorOccurred'), message, DEFAULT_DURATION_IN_SECONDS, 'exclamation-triangle') + console.error(i18n.t(messageI18nKey, messageI18nOptions), error) + void dispatchUiNotification('common.errorOccurred', messageI18nKey, { + contentI18nOptions: messageI18nOptions, + icon: 'exclamation-triangle' + }) } diff --git a/src/redux/ui-notifications/reducers.ts b/src/redux/ui-notifications/reducers.ts index f4246cddb..747f3fa21 100644 --- a/src/redux/ui-notifications/reducers.ts +++ b/src/redux/ui-notifications/reducers.ts @@ -5,7 +5,7 @@ */ import { Reducer } from 'redux' -import { UiNotificationActions, UiNotificationActionType, UiNotificationState } from './types' +import { UiNotification, UiNotificationActions, UiNotificationActionType, UiNotificationState } from './types' export const UiNotificationReducer: Reducer = ( state: UiNotificationState = [], @@ -13,7 +13,7 @@ export const UiNotificationReducer: Reducer { switch (action.type) { case UiNotificationActionType.DISPATCH_NOTIFICATION: - return state.concat(action.notification) + return addNewNotification(state, action.notification, action.notificationIdCallback) case UiNotificationActionType.DISMISS_NOTIFICATION: return dismissNotification(state, action.notificationId) default: @@ -21,6 +21,23 @@ export const UiNotificationReducer: Reducer void +): UiNotificationState => { + const newState = [...state, notification] + notificationIdCallback(newState.length - 1) + return newState +} + const dismissNotification = ( notificationState: UiNotificationState, notificationIndex: number diff --git a/src/redux/ui-notifications/types.ts b/src/redux/ui-notifications/types.ts index 8aa479afc..c4188fa9b 100644 --- a/src/redux/ui-notifications/types.ts +++ b/src/redux/ui-notifications/types.ts @@ -7,6 +7,7 @@ import { Action } from 'redux' import { DateTime } from 'luxon' import { IconName } from '../../components/common/fork-awesome/types' +import { TOptions } from 'i18next' export enum UiNotificationActionType { DISPATCH_NOTIFICATION = 'notification/dispatch', @@ -18,14 +19,19 @@ export interface UiNotificationButton { onClick: () => void } -export interface UiNotification { - title: string - date: DateTime - content: string - dismissed: boolean - icon?: IconName +export interface DispatchOptions { + titleI18nOptions: TOptions | string + contentI18nOptions: TOptions | string durationInSecond: number - buttons?: UiNotificationButton[] + icon?: IconName + buttons: UiNotificationButton[] +} + +export interface UiNotification extends DispatchOptions { + titleI18nKey: string + contentI18nKey: string + date: DateTime + dismissed: boolean } export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNotificationAction @@ -33,6 +39,7 @@ export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNoti export interface DispatchUiNotificationAction extends Action { type: UiNotificationActionType.DISPATCH_NOTIFICATION notification: UiNotification + notificationIdCallback: (notificationId: number) => void } export interface DismissUiNotificationAction extends Action {