From 909f45bc3f51224272ce616452cdc1799c37ef61 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Sat, 14 Oct 2023 03:37:30 +0200 Subject: [PATCH] enhancement(notifications): remove from state after dismissal This commit changes the implementation from an array to a Map, as that makes accessing a notification for dismissal or removal more performant. Signed-off-by: Erik Michelson --- .../ui-notification-boundary.tsx | 71 +++++++++++-------- .../notifications/ui-notification-toast.tsx | 10 ++- .../notifications/ui-notifications.tsx | 4 +- .../src/test-utils/mock-ui-notifications.ts | 3 +- 4 files changed, 54 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/notifications/ui-notification-boundary.tsx b/frontend/src/components/notifications/ui-notification-boundary.tsx index 90896abff..785822bd2 100644 --- a/frontend/src/components/notifications/ui-notification-boundary.tsx +++ b/frontend/src/components/notifications/ui-notification-boundary.tsx @@ -10,10 +10,11 @@ import { UiNotifications } from './ui-notifications' import type { TOptions } from 'i18next' import { DateTime } from 'luxon' import type { PropsWithChildren } from 'react' -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' +import React, { createContext, useCallback, useContext, useMemo } from 'react' import { ExclamationTriangle as IconExclamationTriangle } from 'react-bootstrap-icons' import { useTranslation } from 'react-i18next' import { v4 as uuid } from 'uuid' +import { useMap } from 'react-use' const log = new Logger('Notifications') @@ -27,6 +28,7 @@ interface UiNotificationContext { showErrorNotification: (messageI18nKey: string, messageI18nOptions?: TOptions) => (error: Error) => void dismissNotification: (notificationUuid: string) => void + pruneNotification: (notificationUuid: string) => void } /** @@ -50,7 +52,9 @@ const uiNotificationContext = createContext(u */ export const UiNotificationBoundary: React.FC = ({ children }) => { const { t } = useTranslation() - const [uiNotifications, setUiNotifications] = useState([]) + const [uiNotifications, { set: setUiNotification, remove: removeUiNotification, get: getUiNotification }] = useMap< + Record + >({}) const dispatchUiNotification = useCallback( ( @@ -58,23 +62,21 @@ export const UiNotificationBoundary: React.FC = ({ children } contentI18nKey: string, { icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial ) => { - setUiNotifications((oldState) => [ - ...oldState, - { - titleI18nKey, - contentI18nKey, - createdAtTimestamp: DateTime.now().toSeconds(), - dismissed: false, - titleI18nOptions: titleI18nOptions ?? {}, - contentI18nOptions: contentI18nOptions ?? {}, - durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS, - buttons: buttons ?? [], - icon: icon, - uuid: uuid() - } - ]) + const notificationUuid = uuid() + setUiNotification(notificationUuid, { + titleI18nKey, + contentI18nKey, + createdAtTimestamp: DateTime.now().toSeconds(), + dismissed: false, + titleI18nOptions: titleI18nOptions ?? {}, + contentI18nOptions: contentI18nOptions ?? {}, + durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS, + buttons: buttons ?? [], + icon: icon, + uuid: notificationUuid + }) }, - [] + [setUiNotification] ) const showErrorNotification = useCallback( @@ -89,24 +91,35 @@ export const UiNotificationBoundary: React.FC = ({ children } [dispatchUiNotification, t] ) - const dismissNotification = useCallback((notificationUuid: string): void => { - setUiNotifications((old) => { - const found = old.find((notification) => notification.uuid === notificationUuid) - if (found === undefined) { - return old + const dismissNotification = useCallback( + (notificationUuid: string): void => { + const entry = getUiNotification(notificationUuid) + if (!entry) { + return } - return old.filter((value) => value.uuid !== notificationUuid).concat({ ...found, dismissed: true }) - }) - }, []) + setUiNotification(notificationUuid, { ...entry, dismissed: true }) + }, + [setUiNotification, getUiNotification] + ) + + const pruneNotification = useCallback( + (notificationUuid: string): void => { + if (!uiNotifications[notificationUuid]) { + return + } + removeUiNotification(notificationUuid) + }, + [uiNotifications, removeUiNotification] + ) const context = useMemo(() => { return { dispatchUiNotification: dispatchUiNotification, showErrorNotification: showErrorNotification, - dismissNotification: dismissNotification + dismissNotification: dismissNotification, + pruneNotification: pruneNotification } - }, [dismissNotification, dispatchUiNotification, showErrorNotification]) - + }, [dismissNotification, dispatchUiNotification, showErrorNotification, pruneNotification]) return ( diff --git a/frontend/src/components/notifications/ui-notification-toast.tsx b/frontend/src/components/notifications/ui-notification-toast.tsx index 7a16726f0..13198438c 100644 --- a/frontend/src/components/notifications/ui-notification-toast.tsx +++ b/frontend/src/components/notifications/ui-notification-toast.tsx @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -31,13 +31,18 @@ export interface UiNotificationProps { export const UiNotificationToast: React.FC = ({ notification }) => { const { t } = useTranslation() const [remainingSteps, setRemainingSteps] = useState(() => notification.durationInSecond * STEPS_PER_SECOND) - const { dismissNotification } = useUiNotifications() + const { dismissNotification, pruneNotification } = useUiNotifications() const dismissNow = useCallback(() => { log.debug(`Dismiss notification ${notification.uuid} immediately`) setRemainingSteps(0) }, [notification.uuid]) + const prune = useCallback(() => { + log.debug(`Prune notification ${notification.uuid} from state`) + pruneNotification(notification.uuid) + }, [pruneNotification, notification.uuid]) + useEffectOnce(() => { log.debug(`Show notification ${notification.uuid}`) }) @@ -97,6 +102,7 @@ export const UiNotificationToast: React.FC = ({ notificatio className={styles.toast} show={!notification.dismissed} onClose={dismissNow} + onExited={prune} {...cypressId('notification-toast')}> diff --git a/frontend/src/components/notifications/ui-notifications.tsx b/frontend/src/components/notifications/ui-notifications.tsx index 6ab742a1e..138392df4 100644 --- a/frontend/src/components/notifications/ui-notifications.tsx +++ b/frontend/src/components/notifications/ui-notifications.tsx @@ -9,7 +9,7 @@ import { UiNotificationToast } from './ui-notification-toast' import React, { useMemo } from 'react' export interface UiNotificationsProps { - notifications: UiNotification[] + notifications: Record } /** @@ -19,7 +19,7 @@ export interface UiNotificationsProps { */ export const UiNotifications: React.FC = ({ notifications }) => { const notificationElements = useMemo(() => { - return notifications + return Object.values(notifications) .sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp) .map((notification) => ) }, [notifications]) diff --git a/frontend/src/test-utils/mock-ui-notifications.ts b/frontend/src/test-utils/mock-ui-notifications.ts index 0247fd312..6680b0044 100644 --- a/frontend/src/test-utils/mock-ui-notifications.ts +++ b/frontend/src/test-utils/mock-ui-notifications.ts @@ -14,6 +14,7 @@ export const mockUiNotifications = () => { jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({ showErrorNotification: jest.fn(), dismissNotification: jest.fn(), - dispatchUiNotification: jest.fn() + dispatchUiNotification: jest.fn(), + pruneNotification: jest.fn() }) }