From a735ab2d445306f83ddd67f78ade9707c8f54d0f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:31:28 -0500 Subject: [PATCH 1/8] feat(notifications): extend toasts for notifications --- .../js_src/lib/components/Errors/Toasts.tsx | 61 +++++++--- .../Notifications/NotificationRenderers.tsx | 113 +++++++++++++----- .../Notifications/Notifications.tsx | 22 +++- .../__tests__/Notifications.test.tsx | 105 ++++++++++++++++ .../lib/components/Notifications/hooks.tsx | 24 ++++ 5 files changed, 278 insertions(+), 47 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index 6a7d769c007..aa1287cb4d1 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -7,7 +7,7 @@ import { mainText } from '../../localization/main'; import type { GetOrSet, RA } from '../../utils/types'; import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; -import { dialogIcons } from '../Atoms/Icons'; +import { dialogIcons, icons } from '../Atoms/Icons'; import { error } from './assert'; type ErrorToast = State< @@ -24,16 +24,16 @@ type ErrorToast = State< } >; -/** - * Toasts are only used for errors at the moment. The biggest reason is this - * simplifies focus management: we just auto-focus the toast when it appears - * and revert the focus back once dismissed. - * - * If extending toasts in the future, need to consider: - * - a keyboard shortcut to focus the toast - * - auto-dismissal of toasts after 6s and a way to disable auto dismissal - */ -export type ToastMessage = ErrorToast; +type NotificationToast = State< + 'Notification', + { + readonly message: LocalizedString; + readonly onClick: () => void; + readonly onDismiss: () => void; + } +>; + +export type ToastMessage = ErrorToast | NotificationToast; export function Toasts({ children, @@ -55,7 +55,9 @@ export function Toasts({ setToasts(removeItem(toasts, index))} + onClose={(): void => + setToasts((toasts) => removeItem(toasts, index)) + } /> ))} @@ -78,19 +80,42 @@ function Toast({ readonly onClose: () => void; }): JSX.Element { const previousFocused = React.useRef(document.activeElement); + const isError = toast.type === 'Error'; return ( -
+
{ - if (element === null) return; + if (element === null || !isError) return; previousFocused.current = document.activeElement; element.focus(); }} - onClick={toast.onClick} + onClick={(): void => { + toast.onClick(); + handleClose(); + }} > - {dialogIcons.error} + {isError ? ( + dialogIcons.error + ) : ( + + {icons.bell} + + )}
{toast.message}
diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 7d51bcd416b..52f913c89f8 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -33,6 +33,63 @@ export type GenericNotification = { readonly payload: IR; }; +const notificationHeadings: IR< + (notification: GenericNotification) => LocalizedString +> = { + 'feed-item-updated': () => notificationsText.feedItemUpdated(), + 'update-feed-failed': () => notificationsText.updateFeedFailed(), + 'dwca-export-complete': () => notificationsText.dwcaExportCompleted(), + 'dwca-export-failed': () => notificationsText.dwcaExportFailed(), + 'query-export-to-csv-complete': () => + notificationsText.queryExportToCsvCompleted(), + 'query-export-to-csv-failed': () => + notificationsText.queryExportToCsvFailed(), + 'query-export-to-kml-complete': () => + notificationsText.queryExportToKmlCompleted(), + 'query-export-to-kml-failed': () => + notificationsText.queryExportToKmlFailed(), + 'query-export-to-webportal-complete': () => + notificationsText.queryExportToWebPortalCompleted(), + 'query-export-to-webportal-failed': () => + notificationsText.queryExportToWebPortalFailed(), + 'dataset-ownership-transferred': (notification) => + notificationsText + .dataSetOwnershipTransferred() + .replace( + '', + notification.payload['previous-owner-name'] ?? '' + ) + .replace( + '', + notification.payload['dataset-name'] ?? '' + ) as LocalizedString, + 'record-merge-starting': () => mergingText.mergingHasStarted(), + 'record-merge-failed': () => mergingText.mergingHasFailed(), + 'record-merge-aborted': () => mergingText.mergingHasBeenCanceled(), + 'record-merge-succeeded': () => mergingText.mergingHasSucceeded(), + 'localityupdate-starting': () => localityText.localityUpdateStarted(), + 'localityupdate-parse-failed': () => + localityText.localityUpdateParseFailure(), + 'localityupdate-failed': () => localityText.localityUpdateFailed(), + 'localityupdate-aborted': () => localityText.localityUpdateCancelled(), + 'localityupdate-parse-succeeded': () => localityText.localityUpdateParsed(), + 'localityupdate-succeeded': () => localityText.localityUpdateSucceeded(), + 'backup-succeeded': () => backupText.databaseBackupCompleted(), + 'backup-failed': () => backupText.databaseBackupFailed(), + 'create-default-tree-starting': () => treeText.defaultTreeTaskStarted(), + 'create-default-tree-failed': () => treeText.defaultTreeTaskFailed(), + 'create-default-tree-cancelled': () => treeText.defaultTreeTaskCancelled(), + 'create-default-tree-completed': () => treeText.defaultTreeTaskCompleted(), + 'collection-creation-starting': () => + setupToolText.collectionCreationStarted(), +}; + +export const getNotificationHeading = ( + notification: GenericNotification +): LocalizedString => + notificationHeadings[notification.type]?.(notification) ?? + notificationsText.notifications(); + export const notificationRenderers: IR< (notification: GenericNotification) => React.ReactNode > = { @@ -40,7 +97,7 @@ export const notificationRenderers: IR< const filename = notification.payload.file; return ( <> - {notificationsText.feedItemUpdated()} + {getNotificationHeading(notification)} {filename !== null && ( - {notificationsText.updateFeedFailed()} + {getNotificationHeading(notification)} - {notificationsText.dwcaExportCompleted()} + {getNotificationHeading(notification)} - {notificationsText.dwcaExportFailed()} + {getNotificationHeading(notification)} - {notificationsText.queryExportToCsvCompleted()} + {getNotificationHeading(notification)} - {notificationsText.queryExportToCsvFailed()} + {getNotificationHeading(notification)} {errorPayload !== undefined && ( - {notificationsText.queryExportToKmlFailed()} + {getNotificationHeading(notification)} {errorPayload !== undefined && ( - {notificationsText.queryExportToKmlCompleted()} + {getNotificationHeading(notification)} - {notificationsText.queryExportToWebPortalCompleted()} + {getNotificationHeading(notification)} - {notificationsText.queryExportToWebPortalFailed()} + {getNotificationHeading(notification)} {errorPayload !== undefined && ( - {mergingText.mergingHasStarted()} + {getNotificationHeading(notification)}

{`${collection?.collectionName} - ${mergeName}`}

@@ -254,7 +311,7 @@ export const notificationRenderers: IR< }); return ( <> - {mergingText.mergingHasFailed()} + {getNotificationHeading(notification)}
{mergingText.retryMerge()} @@ -272,7 +329,7 @@ export const notificationRenderers: IR< return ( <> - {mergingText.mergingHasBeenCanceled()} + {getNotificationHeading(notification)}

{`${collection?.collectionName} - ${mergeName}`}

@@ -292,7 +349,7 @@ export const notificationRenderers: IR< return ( resource !== undefined && ( <> - {mergingText.mergingHasSucceeded()} + {getNotificationHeading(notification)}
@@ -304,7 +361,7 @@ export const notificationRenderers: IR< 'localityupdate-starting'(notification) { return ( <> -

{localityText.localityUpdateStarted()}

+

{getNotificationHeading(notification)}

{localityText.taskId()} {notification.payload.taskid} @@ -316,7 +373,7 @@ export const notificationRenderers: IR< const [isOpen, handleOpen, handleClose] = useBooleanState(); return ( <> -

{localityText.localityUpdateParseFailure()}

+

{getNotificationHeading(notification)}

{localityText.localityUpdateFailureResults()} @@ -340,7 +397,7 @@ export const notificationRenderers: IR< const [isOpen, handleOpen, handleClose] = useBooleanState(); return ( <> -

{localityText.localityUpdateFailed()}

+

{getNotificationHeading(notification)}

{localityText.localityUpdateFailureResults()} @@ -361,7 +418,7 @@ export const notificationRenderers: IR< 'localityupdate-aborted'(notification) { return ( <> -

{localityText.localityUpdateCancelled()}

+

{getNotificationHeading(notification)}

{localityText.taskId()} {notification.payload.taskid} @@ -372,7 +429,7 @@ export const notificationRenderers: IR< 'localityupdate-parse-succeeded'(notification) { return ( <> -

{localityText.localityUpdateParsed()}

+

{getNotificationHeading(notification)}

{localityText.taskId()} {notification.payload.taskid} @@ -384,7 +441,7 @@ export const notificationRenderers: IR< const [isOpen, handleOpen, handleClose] = useBooleanState(); return ( <> -

{localityText.localityUpdateSucceeded()}

+

{getNotificationHeading(notification)}

{localityText.localityUpdateResults()} @@ -413,7 +470,7 @@ export const notificationRenderers: IR< const filename = notification.payload.file as unknown as string | undefined; return ( <> - {backupText.databaseBackupCompleted()} + {getNotificationHeading(notification)} {filename && ( -

{backupText.databaseBackupFailed()}

+

{getNotificationHeading(notification)}

{notification.payload.traceback && (
Traceback @@ -444,7 +501,7 @@ export const notificationRenderers: IR< 'create-default-tree-starting'(notification) { return ( <> -

{treeText.defaultTreeTaskStarted()}

+

{getNotificationHeading(notification)}

{notification.payload.name} ); @@ -452,7 +509,7 @@ export const notificationRenderers: IR< 'create-default-tree-failed'(notification) { return ( <> -

{treeText.defaultTreeTaskFailed()}

+

{getNotificationHeading(notification)}

{notification.payload.name} ); @@ -460,7 +517,7 @@ export const notificationRenderers: IR< 'create-default-tree-cancelled'(notification) { return ( <> -

{treeText.defaultTreeTaskCancelled()}

+

{getNotificationHeading(notification)}

{notification.payload.name} ); @@ -468,13 +525,13 @@ export const notificationRenderers: IR< 'create-default-tree-completed'(notification) { return ( <> -

{treeText.defaultTreeTaskCompleted()}

+

{getNotificationHeading(notification)}

{notification.payload.name} ); }, - 'collection-creation-starting'() { - return

{setupToolText.collectionCreationStarted()}

; + 'collection-creation-starting'(notification) { + return

{getNotificationHeading(notification)}

; }, default(notification) { console.error('Unknown notification type', { notification }); diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx index 1f49d045e55..6a835bd5aaf 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx @@ -6,15 +6,20 @@ import { notificationsText } from '../../localization/notifications'; import { formData } from '../../utils/ajax/helpers'; import { ping } from '../../utils/ajax/ping'; import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; +import { SetToastsContext } from '../Errors/Toasts'; import { MenuButton } from '../Header/index'; import { DateElement } from '../Molecules/DateElement'; import { Dialog, dialogClassNames, LoadingScreen } from '../Molecules/Dialog'; import { useNotificationsFetch } from './hooks'; import type { GenericNotification } from './NotificationRenderers'; -import { notificationRenderers } from './NotificationRenderers'; +import { + getNotificationHeading, + notificationRenderers, +} from './NotificationRenderers'; export function Notifications({ isCollapsed, @@ -23,10 +28,25 @@ export function Notifications({ }): JSX.Element { const [isOpen, handleOpen, handleClose] = useBooleanState(); const freezeFetchPromise = React.useRef | undefined>(undefined); + const setToasts = React.useContext(SetToastsContext); + const handleNewNotifications = React.useCallback( + (newNotifications: RA): void => + setToasts((toasts) => [ + ...toasts, + ...newNotifications.map((notification) => ({ + type: 'Notification' as const, + message: getNotificationHeading(notification), + onClick: handleOpen, + onDismiss: f.void, + })), + ]), + [handleOpen, setToasts] + ); const { notifications, setNotifications } = useNotificationsFetch({ freezeFetchPromise, isOpen, + onNewNotifications: handleNewNotifications, }); const notificationCount = notifications?.length; diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx new file mode 100644 index 00000000000..8fb1507c544 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -0,0 +1,105 @@ +import { act, within } from '@testing-library/react'; +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { mount } from '../../../tests/reactUtils'; +import { Toasts } from '../../Errors/Toasts'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { Notifications } from '../Notifications'; +import type { GenericNotification } from '../NotificationRenderers'; +import { useNotificationsFetch } from '../hooks'; + +jest.mock('../hooks'); + +const mockedUseNotificationsFetch = jest.mocked(useNotificationsFetch); + +const makeNotification = ( + messageId: string, + type: string, + file: string +): GenericNotification => ({ + messageId, + read: false, + timestamp: `2023-09-19T01:22:0${messageId}`, + type, + payload: { file: file as LocalizedString }, +}); + +test('new notification toasts open the dialog with current notifications', async () => { + let notifications: readonly GenericNotification[] = []; + let handleIncoming: + | ((notifications: readonly GenericNotification[]) => void) + | undefined; + + mockedUseNotificationsFetch.mockImplementation(({ onNewNotifications }) => { + handleIncoming = onNewNotifications; + return { + notifications, + setNotifications: jest.fn(), + }; + }); + + const view = mount( + + + + + + ); + const dwca = makeNotification('1', 'dwca-export-complete', 'export.zip'); + const csv = makeNotification( + '2', + 'query-export-to-csv-complete', + 'query.csv' + ); + notifications = [csv, dwca]; + + act(() => { + handleIncoming?.(notifications); + view.rerender( + + + + + + ); + }); + + const csvToast = view.getByRole('button', { + name: /Query export to CSV completed/i, + }); + expect(csvToast).toHaveAttribute('aria-live', 'polite'); + expect(csvToast.parentElement).toHaveClass( + 'bg-gray-200', + 'hover:bg-gray-300', + 'dark:bg-neutral-800' + ); + expect(csvToast).toHaveClass( + '!text-black', + 'hover:!text-black', + 'dark:!text-gray-100', + 'dark:hover:!text-gray-100' + ); + expect(csvToast.querySelector('svg')?.parentElement).toHaveClass( + 'text-green-600', + 'dark:text-green-400' + ); + const dwcaToast = view.getByRole('button', { + name: /DwCA export completed/i, + }); + await view.user.click( + within(dwcaToast.parentElement ?? dwcaToast).getByRole('button', { + name: 'Dismiss', + }) + ); + expect(dwcaToast).not.toBeInTheDocument(); + + await view.user.click(csvToast); + + const dialog = view.getByRole('dialog'); + expect(dialog).toHaveTextContent('Query export to CSV completed.'); + expect(dialog).toHaveTextContent('DwCA export completed.'); + expect( + within(dialog).getAllByRole('link', { name: 'Download' }) + ).toHaveLength(2); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/hooks.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/hooks.tsx index da994f7a2bc..75ed65beb0c 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/hooks.tsx @@ -16,11 +16,15 @@ const INTERVAL_MULTIPLIER = 1.1; export function useNotificationsFetch({ freezeFetchPromise, isOpen, + onNewNotifications, }: { readonly freezeFetchPromise: React.MutableRefObject< Promise | undefined >; readonly isOpen: boolean; + readonly onNewNotifications?: ( + notifications: RA + ) => void; }): { readonly notifications: RA | undefined; readonly setNotifications: React.Dispatch< @@ -32,6 +36,11 @@ export function useNotificationsFetch({ >(undefined); const lastRawTimeRef = React.useRef(undefined); + const knownMessageIds = React.useRef | undefined>( + undefined + ); + const onNewNotificationsRef = React.useRef(onNewNotifications); + onNewNotificationsRef.current = onNewNotifications; React.useEffect(() => { let pullInterval = INITIAL_INTERVAL; @@ -93,6 +102,21 @@ export function useNotificationsFetch({ .then(({ data: newNotifications }) => { if (destructorCalled) return; + const unseenNotifications = newNotifications.filter( + ({ message_id }) => !knownMessageIds.current?.has(message_id) + ); + if ( + knownMessageIds.current !== undefined && + unseenNotifications.length > 0 + ) + onNewNotificationsRef.current?.( + mergeAndSortNotifications(undefined, unseenNotifications) + ); + knownMessageIds.current = new Set([ + ...(knownMessageIds.current ?? []), + ...newNotifications.map(({ message_id }) => message_id), + ]); + setNotifications((existingNotifications) => mergeAndSortNotifications(existingNotifications, newNotifications) ); From 399da00b3ea8873a9aaa9fe8f0bef35383660af0 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:34:56 -0500 Subject: [PATCH 2/8] fix(notifications): guard heading lookup --- .../Notifications/NotificationRenderers.tsx | 12 +++++++++--- .../Notifications/__tests__/Notifications.test.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 52f913c89f8..49f76e009e5 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -86,9 +86,15 @@ const notificationHeadings: IR< export const getNotificationHeading = ( notification: GenericNotification -): LocalizedString => - notificationHeadings[notification.type]?.(notification) ?? - notificationsText.notifications(); +): LocalizedString => { + const heading = Object.prototype.hasOwnProperty.call( + notificationHeadings, + notification.type + ) + ? notificationHeadings[notification.type] + : undefined; + return heading?.(notification) ?? notificationsText.notifications(); +}; export const notificationRenderers: IR< (notification: GenericNotification) => React.ReactNode diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index 8fb1507c544..316f1d675b7 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -6,7 +6,10 @@ import { mount } from '../../../tests/reactUtils'; import { Toasts } from '../../Errors/Toasts'; import { UnloadProtectsContext } from '../../Router/UnloadProtect'; import { Notifications } from '../Notifications'; -import type { GenericNotification } from '../NotificationRenderers'; +import { + getNotificationHeading, + type GenericNotification, +} from '../NotificationRenderers'; import { useNotificationsFetch } from '../hooks'; jest.mock('../hooks'); @@ -25,6 +28,12 @@ const makeNotification = ( payload: { file: file as LocalizedString }, }); +test('unknown notification types use the generic heading', () => { + expect(getNotificationHeading(makeNotification('1', '__proto__', ''))).toBe( + 'Notifications' + ); +}); + test('new notification toasts open the dialog with current notifications', async () => { let notifications: readonly GenericNotification[] = []; let handleIncoming: From 40df6db29d6077337e94f3734ea960936b5440bf Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:38:49 -0500 Subject: [PATCH 3/8] fix(notifications): remove stale notification toasts --- .../js_src/lib/components/Errors/Toasts.tsx | 1 + .../Notifications/Notifications.tsx | 14 ++++++++++ .../__tests__/Notifications.test.tsx | 26 ++++++++++++++++--- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index aa1287cb4d1..1bf54d21076 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -27,6 +27,7 @@ type ErrorToast = State< type NotificationToast = State< 'Notification', { + readonly messageId: string; readonly message: LocalizedString; readonly onClick: () => void; readonly onDismiss: () => void; diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx index 6a835bd5aaf..dad39b69265 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/Notifications.tsx @@ -35,6 +35,7 @@ export function Notifications({ ...toasts, ...newNotifications.map((notification) => ({ type: 'Notification' as const, + messageId: notification.messageId, message: getNotificationHeading(notification), onClick: handleOpen, onDismiss: f.void, @@ -64,6 +65,17 @@ export function Notifications({ }) : notificationsText.notificationsLoading(); + const removeNotificationToasts = React.useCallback( + (messageIds: ReadonlySet): void => + setToasts((toasts) => + toasts.filter( + (toast) => + toast.type !== 'Notification' || !messageIds.has(toast.messageId) + ) + ), + [setToasts] + ); + function handleClearAll() { if (notifications === undefined) return; const message_ids = notifications.map(({ messageId }) => messageId); @@ -76,6 +88,7 @@ export function Notifications({ }).then(() => { // After the notifications are deleted on the server, clear them from the local state setNotifications([]); + removeNotificationToasts(new Set(message_ids)); }); } @@ -149,6 +162,7 @@ export function Notifications({ notification={notification} onDelete={(promise): void => { freezeFetchPromise.current = promise; + removeNotificationToasts(new Set([notification.messageId])); setNotifications( notifications.filter((item) => item !== notification) ); diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index 316f1d675b7..0b15d447738 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; import { mount } from '../../../tests/reactUtils'; +import { ping } from '../../../utils/ajax/ping'; import { Toasts } from '../../Errors/Toasts'; import { UnloadProtectsContext } from '../../Router/UnloadProtect'; import { Notifications } from '../Notifications'; @@ -13,8 +14,10 @@ import { import { useNotificationsFetch } from '../hooks'; jest.mock('../hooks'); +jest.mock('../../../utils/ajax/ping'); const mockedUseNotificationsFetch = jest.mocked(useNotificationsFetch); +const mockedPing = jest.mocked(ping); const makeNotification = ( messageId: string, @@ -35,6 +38,7 @@ test('unknown notification types use the generic heading', () => { }); test('new notification toasts open the dialog with current notifications', async () => { + mockedPing.mockResolvedValue(200); let notifications: readonly GenericNotification[] = []; let handleIncoming: | ((notifications: readonly GenericNotification[]) => void) @@ -61,7 +65,12 @@ test('new notification toasts open the dialog with current notifications', async 'query-export-to-csv-complete', 'query.csv' ); - notifications = [csv, dwca]; + const kml = makeNotification( + '3', + 'query-export-to-kml-complete', + 'query.kml' + ); + notifications = [csv, dwca, kml]; act(() => { handleIncoming?.(notifications); @@ -96,19 +105,30 @@ test('new notification toasts open the dialog with current notifications', async const dwcaToast = view.getByRole('button', { name: /DwCA export completed/i, }); + expect( + view.getByRole('button', { name: /Query export to KML completed/i }) + ).toBeInTheDocument(); await view.user.click( within(dwcaToast.parentElement ?? dwcaToast).getByRole('button', { name: 'Dismiss', }) ); - expect(dwcaToast).not.toBeInTheDocument(); + expect( + view.queryByRole('button', { name: /DwCA export completed/i }) + ).not.toBeInTheDocument(); await view.user.click(csvToast); const dialog = view.getByRole('dialog'); expect(dialog).toHaveTextContent('Query export to CSV completed.'); expect(dialog).toHaveTextContent('DwCA export completed.'); + await view.user.click( + within(dialog).getAllByRole('button', { name: 'Delete' })[2]! + ); + expect( + view.queryByRole('button', { name: /Query export to KML completed/i }) + ).not.toBeInTheDocument(); expect( within(dialog).getAllByRole('link', { name: 'Download' }) - ).toHaveLength(2); + ).toHaveLength(3); }); From 0dd432f4c09c7326f616c6fe33e64d213e3e1e0b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:21:21 -0500 Subject: [PATCH 4/8] feat(notifications): disappear after 10 seconds inspired heavily by https://codepen.io/LOVEFORALL/pen/RwBgMQO --- specifyweb/frontend/js_src/css/main.css | 19 ++++++- .../js_src/lib/components/Errors/Toasts.tsx | 55 +++++++++++++++++-- .../__tests__/Notifications.test.tsx | 50 ++++++++++++++++- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index dfa4a1e1f05..a8412466cb2 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -4,6 +4,20 @@ */ @tailwind base; +@keyframes notification-toast-progress { + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +} + +.notification-toast-progress { + animation: notification-toast-progress 5s linear forwards; +} + /* * REFACTOR: get rid of most global styles once everything is using React components * (can use arbitrary variants too) @@ -36,8 +50,9 @@ * Disables transitions for text color. * See https://github.com/specify/specify7/issues/1561 */ - transition-property: background-color, border-color, text-decoration-color, - fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: + background-color, border-color, text-decoration-color, fill, stroke, + opacity, box-shadow, transform, filter, backdrop-filter; } [type='text'], diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index 1bf54d21076..1c92cb0381c 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -5,11 +5,12 @@ import type { State } from 'typesafe-reducer'; import { commonText } from '../../localization/common'; import { mainText } from '../../localization/main'; import type { GetOrSet, RA } from '../../utils/types'; -import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { dialogIcons, icons } from '../Atoms/Icons'; import { error } from './assert'; +export const NOTIFICATION_TOAST_DURATION = 10_000; + type ErrorToast = State< 'Error', { @@ -54,10 +55,16 @@ export function Toasts({ > {toasts.map((toast, index) => ( - setToasts((toasts) => removeItem(toasts, index)) + setToasts((toasts) => + toasts.filter((currentToast) => currentToast !== toast) + ) } /> ))} @@ -82,10 +89,28 @@ function Toast({ }): JSX.Element { const previousFocused = React.useRef(document.activeElement); const isError = toast.type === 'Error'; + const handleCloseRef = React.useRef(handleClose); + handleCloseRef.current = handleClose; + const remainingTime = React.useRef(NOTIFICATION_TOAST_DURATION); + const [isTimerPaused, setIsTimerPaused] = React.useState(false); + + React.useEffect(() => { + if (isError || isTimerPaused) return undefined; + const startedAt = Date.now(); + const timeout = globalThis.setTimeout(() => { + toast.onDismiss(); + handleCloseRef.current(); + }, remainingTime.current); + return (): void => { + globalThis.clearTimeout(timeout); + remainingTime.current -= Date.now() - startedAt; + }; + }, [isError, isTimerPaused, toast]); + return (
{ + if (!event.currentTarget.contains(event.relatedTarget)) + setIsTimerPaused(false); + }} + onFocus={(): void => setIsTimerPaused(true)} > + {icons.bell} )} @@ -128,11 +158,24 @@ function Toast({ icon="x" title={commonText.dismiss()} onClick={(): void => { - (previousFocused.current as HTMLElement | null)?.focus(); + if (isError) (previousFocused.current as HTMLElement | null)?.focus(); toast.onDismiss(); handleClose(); }} /> + {!isError && ( + + )}
); } diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index 0b15d447738..4fd5754d506 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -4,7 +4,11 @@ import type { LocalizedString } from 'typesafe-i18n'; import { mount } from '../../../tests/reactUtils'; import { ping } from '../../../utils/ajax/ping'; -import { Toasts } from '../../Errors/Toasts'; +import { + NOTIFICATION_TOAST_DURATION, + SetToastsContext, + Toasts, +} from '../../Errors/Toasts'; import { UnloadProtectsContext } from '../../Router/UnloadProtect'; import { Notifications } from '../Notifications'; import { @@ -37,6 +41,46 @@ test('unknown notification types use the generic heading', () => { ); }); +function NotificationToastHarness(): null { + const setToasts = React.useContext(SetToastsContext); + React.useEffect(() => { + setToasts([ + { + type: 'Notification', + messageId: 'timed', + message: 'Timed notification' as LocalizedString, + onClick: jest.fn(), + onDismiss: jest.fn(), + }, + ]); + }, [setToasts]); + return null; +} + +test('notification toasts disappear after ten seconds', () => { + jest.useFakeTimers(); + try { + const view = mount( + + + + ); + const toast = view.getByRole('button', { name: /Timed notification/i }); + expect( + toast.parentElement?.querySelector('.notification-toast-progress') + ).toHaveStyle({ animationPlayState: 'running' }); + + act(() => jest.advanceTimersByTime(NOTIFICATION_TOAST_DURATION - 1)); + expect(toast).toBeInTheDocument(); + act(() => jest.advanceTimersByTime(1)); + expect( + view.queryByRole('button', { name: /Timed notification/i }) + ).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + test('new notification toasts open the dialog with current notifications', async () => { mockedPing.mockResolvedValue(200); let notifications: readonly GenericNotification[] = []; @@ -99,8 +143,8 @@ test('new notification toasts open the dialog with current notifications', async 'dark:hover:!text-gray-100' ); expect(csvToast.querySelector('svg')?.parentElement).toHaveClass( - 'text-green-600', - 'dark:text-green-400' + 'text-brand-300', + 'dark:text-brand-400' ); const dwcaToast = view.getByRole('button', { name: /DwCA export completed/i, From 16aeb3be72fd029347be013b7efaefcb307a913e Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:43:26 -0500 Subject: [PATCH 5/8] test(notifications): fix dialog state after deletion --- .../__tests__/Notifications.test.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index 4fd5754d506..b1c0cc4ad0c 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -83,17 +83,19 @@ test('notification toasts disappear after ten seconds', () => { test('new notification toasts open the dialog with current notifications', async () => { mockedPing.mockResolvedValue(200); - let notifications: readonly GenericNotification[] = []; let handleIncoming: | ((notifications: readonly GenericNotification[]) => void) | undefined; mockedUseNotificationsFetch.mockImplementation(({ onNewNotifications }) => { - handleIncoming = onNewNotifications; - return { - notifications, - setNotifications: jest.fn(), + const [notifications, setNotifications] = React.useState< + readonly GenericNotification[] | undefined + >([]); + handleIncoming = (incomingNotifications): void => { + onNewNotifications?.(incomingNotifications); + setNotifications(incomingNotifications); }; + return { notifications, setNotifications }; }); const view = mount( @@ -114,17 +116,8 @@ test('new notification toasts open the dialog with current notifications', async 'query-export-to-kml-complete', 'query.kml' ); - notifications = [csv, dwca, kml]; - act(() => { - handleIncoming?.(notifications); - view.rerender( - - - - - - ); + handleIncoming?.([csv, dwca, kml]); }); const csvToast = view.getByRole('button', { @@ -174,5 +167,5 @@ test('new notification toasts open the dialog with current notifications', async ).not.toBeInTheDocument(); expect( within(dialog).getAllByRole('link', { name: 'Download' }) - ).toHaveLength(3); + ).toHaveLength(2); }); From 1d00bc5847afdd545052afbb61cb565be322407d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:50:21 -0500 Subject: [PATCH 6/8] fix(notifications): simplify toast progress --- specifyweb/frontend/js_src/css/main.css | 4 ---- specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index a8412466cb2..de88b57b0f5 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -14,10 +14,6 @@ } } -.notification-toast-progress { - animation: notification-toast-progress 5s linear forwards; -} - /* * REFACTOR: get rid of most global styles once everything is using React components * (can use arbitrary variants too) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index 1c92cb0381c..d1fa543ebca 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -171,7 +171,7 @@ function Toast({ origin-left bg-brand-300 dark:bg-brand-400 `} style={{ - animationDuration: `${NOTIFICATION_TOAST_DURATION}ms`, + animation: `notification-toast-progress ${NOTIFICATION_TOAST_DURATION}ms linear forwards`, animationPlayState: isTimerPaused ? 'paused' : 'running', }} /> From fe64f75f077d2a1c9d39b88893df2538307977ec Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:42:11 -0500 Subject: [PATCH 7/8] fix(notifications): make sure screen readers can read toast --- .../js_src/lib/components/Errors/Toasts.tsx | 15 ++++++++++++++- .../Notifications/Notifications.tsx | 1 - .../__tests__/Notifications.test.tsx | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index d1fa543ebca..63cc90cf91e 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -43,9 +43,23 @@ export function Toasts({ readonly children: JSX.Element; }): JSX.Element { const [toasts, setToasts] = React.useState>([]); + const [announcement, setAnnouncement] = React.useState(''); + const previousToasts = React.useRef(toasts); + React.useEffect(() => { + const addedNotification = toasts.find( + (toast) => + toast.type === 'Notification' && !previousToasts.current.includes(toast) + ); + previousToasts.current = toasts; + if (addedNotification !== undefined) + setAnnouncement(addedNotification.message); + }, [toasts]); return ( {children} + + {announcement} + {toasts.length > 0 && (
setIsTimerPaused(true)} > 0 && !isOpen ? '[&:not(:hover)]:!text-brand-200 [&:not(:hover)]:dark:!text-brand-400' diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index b1c0cc4ad0c..c29029f7be2 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -81,6 +81,23 @@ test('notification toasts disappear after ten seconds', () => { } }); +test('notification toast headings are announced', async () => { + const view = mount( + + + + ); + const liveRegion = view.container.querySelector('[aria-live="polite"]'); + expect(liveRegion).toBeInTheDocument(); + expect(liveRegion).toHaveTextContent('Timed notification'); + expect( + view.getByRole('button', { name: /Timed notification/i }) + ).not.toHaveAttribute('aria-live'); + expect(view.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); + await view.user.click(view.getByRole('button', { name: 'Dismiss' })); + expect(liveRegion).toBeInTheDocument(); +}); + test('new notification toasts open the dialog with current notifications', async () => { mockedPing.mockResolvedValue(200); let handleIncoming: @@ -123,7 +140,7 @@ test('new notification toasts open the dialog with current notifications', async const csvToast = view.getByRole('button', { name: /Query export to CSV completed/i, }); - expect(csvToast).toHaveAttribute('aria-live', 'polite'); + expect(csvToast).not.toHaveAttribute('aria-live'); expect(csvToast.parentElement).toHaveClass( 'bg-gray-200', 'hover:bg-gray-300', From 914aebbd513f2145b5a33548c732f615abbb7d1c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:47:48 -0500 Subject: [PATCH 8/8] fix(notifications): increase contrast in toast --- specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx | 2 +- .../components/Notifications/__tests__/Notifications.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx index 63cc90cf91e..7b3b214b12b 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/Toasts.tsx @@ -128,7 +128,7 @@ function Toast({ ${ isError ? 'border-red-500 bg-red-200 hover:bg-red-300 dark:bg-red-900 dark:hover:bg-red-800' - : `border-gray-400 bg-gray-200 hover:bg-gray-300 + : `border-gray-400 bg-gray-100 hover:bg-gray-200 dark:border-gray-600 dark:bg-neutral-800 dark:hover:bg-neutral-700` } `} diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx index c29029f7be2..fe8b2181bd1 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/__tests__/Notifications.test.tsx @@ -142,8 +142,8 @@ test('new notification toasts open the dialog with current notifications', async }); expect(csvToast).not.toHaveAttribute('aria-live'); expect(csvToast.parentElement).toHaveClass( - 'bg-gray-200', - 'hover:bg-gray-300', + 'bg-gray-100', + 'hover:bg-gray-200', 'dark:bg-neutral-800' ); expect(csvToast).toHaveClass(