diff --git a/i18n/keys.json b/i18n/keys.json index 3a514264..6ff1ae4a 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -223,6 +223,10 @@ "toast.prefix.warning": { "defaultMessage": "Warning:" }, + "toast.repetition-count": { + "defaultMessage": "Shown {count} times", + "description": "Screen reader label for a toast shown multiple times" + }, "toasts.keyboard_shortcut_aria_label": { "defaultMessage": "Focus toasts messages with", "description": "ARIA-label for the toasts container keyboard shortcut" diff --git a/src/common/components/Toast.tsx b/src/common/components/Toast.tsx index ef05f6be..56b4c32e 100644 --- a/src/common/components/Toast.tsx +++ b/src/common/components/Toast.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { css, Global } from '@emotion/react'; import styled from '@emotion/styled'; import { forwardRef, ReactNode, useCallback, useMemo } from 'react'; @@ -24,7 +25,7 @@ import { useIntl } from 'react-intl'; import { toast as sonnerToast } from 'sonner'; import { TextNode, TextNodeOptional } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; -import { ButtonIcon, Spinner, Text } from '../../components'; +import { BadgeCounter, ButtonIcon, Spinner, Text } from '../../components'; import { IconCheckCircle, IconError, IconInfo, IconWarning, IconX } from '../../components/icons'; import { ScreenReaderPrefix } from './ScreenReaderPrefix'; @@ -68,7 +69,8 @@ interface ToastActionsParams { */ id: ToastId; /** - * Function to programmatically dismiss the toast, after clicking an action we should normally also dismiss the toast. + * Function to programmatically dismiss the toast. After clicking an action, + * we should normally also dismiss the toast. */ dismiss: VoidFunction; } @@ -77,7 +79,8 @@ export interface ToastProps { /** * Custom actions to display in the toast (optional). Receives the toast ID * and dismiss function as parameters. It should be either one or more Buttons or Links. - * If provided, the toast should also have the `isDismissable` prop set to true and it's duration set to infinite. + * If provided, the toast should also have the `isDismissable` prop set to true + * and its duration set to infinite. */ actions?: ({ id, dismiss }: ToastActionsParams) => ReactNode; /** @@ -95,6 +98,11 @@ export interface ToastProps { * The default is false. */ isDismissable?: boolean; + /** + * Number of times the same toast has been repeated. When greater than 1, a counter badge is + * displayed next to the toast. + */ + repetitionCount?: number; /** * Optional prefix text for screen readers, providing additional context. * If not provided, a default message based on the toast variety will be used. @@ -142,12 +150,39 @@ export const Toast = forwardRef>((props, re description, id, isDismissable = false, + repetitionCount: repetitionCountProp, screenReaderPrefix, title, variety, ...htmlProps } = props; + const intl = useIntl(); + const repetitionCount = repetitionCountProp ?? 0; + const hasVisibleTitle = hasVisibleToastTitle(title); + + // Keep the visible badge compact while screen readers still hear the full count. + const repetitionBadgeValue = repetitionCount > 99 ? '99+' : repetitionCount; + + const repetitionCounter = + repetitionCount > 1 ? ( + <> + + {intl.formatMessage( + { + id: 'toast.repetition-count', + defaultMessage: 'Shown {count} times', + description: 'Screen reader label for a toast shown multiple times', + }, + { count: repetitionCount }, + )} + + + + + + + ) : undefined; const handleDismiss = useCallback(() => { sonnerToast.dismiss(id); @@ -156,27 +191,49 @@ export const Toast = forwardRef>((props, re return ( {TOAST_VARIETY_ICONS[variety]} + {screenReaderPrefix ?? } - {title && {title}} - {description} + + {hasVisibleTitle && ( + + {title} + + {repetitionCounter} + + )} + + {hasVisibleTitle ? ( + {description} + ) : ( + + {description} + + {repetitionCounter} + + )} + {actions?.({ id, dismiss: handleDismiss })} + {isDismissable && ( - + + + )} ); @@ -217,6 +274,27 @@ const ToastContent = styled.div` `; ToastContent.displayName = 'ToastContent'; +const ToastTextLine = styled.div` + display: inline-flex; + align-items: center; + gap: ${cssVar('dimension-space-100')}; + max-width: 100%; + + & > *:first-child { + min-width: 0; + } +`; +ToastTextLine.displayName = 'ToastTextLine'; + +const ToastTrailingContent = styled.div` + display: flex; + align-items: center; + gap: ${cssVar('dimension-space-100')}; + flex-shrink: 0; + min-height: ${cssVar('dimension-height-600')}; +`; +ToastTrailingContent.displayName = 'ToastTrailingContent'; + const ToastDismissButton = styled(ButtonIcon)` --button-padding: ${cssVar('dimension-space-0')}; --button-height: ${cssVar('dimension-height-600')}; @@ -224,6 +302,23 @@ const ToastDismissButton = styled(ButtonIcon)` `; ToastDismissButton.displayName = 'ToastDismissButton'; +const ToastRepetitionCounter = styled.span` + flex-shrink: 0; +`; +ToastRepetitionCounter.displayName = 'ToastRepetitionCounter'; + +function hasVisibleToastTitle(title: TextNodeOptional | undefined): boolean { + if (typeof title === 'string') { + return title.trim().length > 0; + } + + if (Array.isArray(title)) { + return title.some((child) => hasVisibleToastTitle(child)); + } + + return Boolean(title); +} + const TOAST_VARIETY_ICONS = { [ToastVariety.Info]: , [ToastVariety.Danger]: , diff --git a/src/common/helpers/test-utils.tsx b/src/common/helpers/test-utils.tsx index 0a2245e3..a4e396cb 100644 --- a/src/common/helpers/test-utils.tsx +++ b/src/common/helpers/test-utils.tsx @@ -17,16 +17,45 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { RenderOptions, RenderResult, render as rtlRender } from '@testing-library/react'; + +import { act, RenderOptions, RenderResult, render as rtlRender } from '@testing-library/react'; import userEvent, { UserEvent, Options as UserEventsOptions } from '@testing-library/user-event'; import React, { ComponentProps, PropsWithChildren } from 'react'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { type ToastId } from '~common/components/Toast'; import { PropsWithLabels, PropsWithLabelsAndHelpText } from '~types/utils'; import { EchoesProvider } from '../../components/echoes-provider'; +import { toast } from '../../utils'; type RenderResultWithUser = RenderResult & { user: UserEvent }; +interface ToastTestStateOptions { + cleanupTrackedToast?: (toastId: ToastId) => void; +} + +function dismissTrackedToasts( + trackedToastIds: ReadonlySet, + cleanupTrackedToast?: (toastId: ToastId) => void, +) { + act(() => { + for (const toastId of trackedToastIds) { + cleanupTrackedToast?.(toastId); + toast.dismiss(toastId); + } + }); +} + +function waitForNextAnimationFrame() { + return new Promise((resolve) => { + globalThis.requestAnimationFrame(() => resolve()); + }); +} + +async function waitForToastDismissAnimationFrame() { + await act(waitForNextAnimationFrame); +} + export function render( ui: React.ReactElement, options?: RenderOptions, @@ -80,6 +109,39 @@ export type OmitPropsWithLabels> = Pa > & PropsWithLabels<{}>; +export function createToastTestState(options: Readonly = {}) { + const trackedToastIds = new Set(); + + function trackToastId(toastId: ToastId) { + // Remember every created toast so afterEach can dismiss leftovers and keep tests isolated. + trackedToastIds.add(toastId); + + return toastId; + } + + async function resetToastTestState() { + const hasTrackedToasts = trackedToastIds.size > 0; + + dismissTrackedToasts(trackedToastIds, options.cleanupTrackedToast); + + trackedToastIds.clear(); + jest.useRealTimers(); + + if (!hasTrackedToasts) { + return; + } + + // Sonner applies part of toast dismissal on the next animation frame, so wait for that + // deferred update inside `act(...)` before the next test starts. + await waitForToastDismissAnimationFrame(); + } + + return { + resetToastTestState, + trackToastId, + }; +} + function ShowPath() { const { pathname } = useLocation(); return
{pathname}
; diff --git a/src/utils/__tests__/toasts-aggregation-ui-test.tsx b/src/utils/__tests__/toasts-aggregation-ui-test.tsx new file mode 100644 index 00000000..6e78c37d --- /dev/null +++ b/src/utils/__tests__/toasts-aggregation-ui-test.tsx @@ -0,0 +1,206 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { createToastTestState, render } from '~common/helpers/test-utils'; +import { toast, ToastDuration } from '..'; +import { clearRepeatedToastTracking } from '../toast-internals/repeated-toast-tracking'; +import { AggregatedRepeatedToasts } from '../../../stories/Toast-stories'; + +const uploadCompleteToast = { + description: 'File uploaded', + title: 'Upload complete', +} as const; + +const descriptionOnlyUploadCompleteToast = { + description: 'File uploaded', +} as const; + +const { resetToastTestState, trackToastId } = createToastTestState({ + cleanupTrackedToast: clearRepeatedToastTracking, +}); + +describe('toast utility - automatic aggregation UI', () => { + afterEach(resetToastTestState); + + it('should aggregate repeated toasts without a title when the description and variety match', async () => { + render(
); + + trackToastId(toast.success(descriptionOnlyUploadCompleteToast)); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + trackToastId(toast.success(descriptionOnlyUploadCompleteToast)); + + expect(await screen.findByText('Shown 2 times')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.queryByText('Upload complete')).not.toBeInTheDocument(); + expect(screen.getByText(/^2$/)).toBeInTheDocument(); + }); + + it('should treat blank plain-text titles as absent when aggregating repeated toasts', async () => { + render(
); + + trackToastId( + toast.success({ + ...descriptionOnlyUploadCompleteToast, + title: ' ', + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + trackToastId(toast.success(descriptionOnlyUploadCompleteToast)); + + expect(await screen.findByText('Shown 2 times')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.queryByText('Upload complete')).not.toBeInTheDocument(); + expect(screen.getByText(/^2$/)).toBeInTheDocument(); + }); + + it('should aggregate repeated toasts with the same plain-text title, description, and variety', async () => { + render(
); + + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + trackToastId(toast.success(uploadCompleteToast)); + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('Shown 3 times')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.getByText('Upload complete')).toBeInTheDocument(); + expect(screen.getByText(/^3$/)).toBeInTheDocument(); + }); + + it('should cap the visible repetition counter at 99 plus', async () => { + render(
); + + for (let i = 1; i <= 100; i++) { + trackToastId(toast.success(uploadCompleteToast)); + } + + expect(await screen.findByText('Shown 100 times')).toBeInTheDocument(); + expect(screen.getByText(/^99\+$/)).toBeInTheDocument(); + }); + + it('should reset the aggregation count after a UI dismiss', async () => { + const { user } = render(
); + + trackToastId( + toast.success({ + ...uploadCompleteToast, + duration: ToastDuration.Infinite, + isDismissable: true, + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + trackToastId( + toast.success({ + ...uploadCompleteToast, + duration: ToastDuration.Infinite, + isDismissable: true, + }), + ); + + expect(await screen.findByText('Shown 2 times')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Dismiss toast' })); + await waitForElementToBeRemoved(() => screen.queryByText('File uploaded')); + + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.queryByText('Shown 2 times')).not.toBeInTheDocument(); + expect(screen.queryByText(/^2$/)).not.toBeInTheDocument(); + }); + + it('should reset aggregation after the first occurrence auto-closes', async () => { + jest.useFakeTimers(); + render(
); + + trackToastId( + toast.info({ + ...uploadCompleteToast, + duration: ToastDuration.Short, + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(8000); + jest.runOnlyPendingTimers(); + }); + + act(() => { + // Sonner finalizes auto-dismiss on the next animation frame. + jest.advanceTimersToNextFrame(); + }); + + await waitFor(() => { + expect(screen.queryByText('File uploaded')).not.toBeInTheDocument(); + }); + + trackToastId( + toast.info({ + ...uploadCompleteToast, + duration: ToastDuration.Short, + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + expect(screen.queryByText('Shown 2 times')).not.toBeInTheDocument(); + }); + + it('should keep the aggregation story aggregating when Storybook injects lifecycle action args', async () => { + jest.useFakeTimers(); + + const storyElement = AggregatedRepeatedToasts.render!( + { + description: 'File uploaded', + isDismissable: true, + onAutoClose: jest.fn(), + onDismiss: jest.fn(), + variety: 'success', + }, + {} as never, + ); + + const { user } = render(<>{storyElement}, undefined, { + advanceTimers: jest.advanceTimersByTime, + }); + + await user.click(screen.getByRole('button', { name: 'Show aggregated repeated toast' })); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(600); + }); + + expect(await screen.findByText('Shown 3 times')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + }); +}); diff --git a/src/utils/__tests__/toasts-test.tsx b/src/utils/__tests__/toasts-test.tsx index 0152b41f..a60510c0 100644 --- a/src/utils/__tests__/toasts-test.tsx +++ b/src/utils/__tests__/toasts-test.tsx @@ -18,47 +18,53 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { act, screen, waitForElementToBeRemoved } from '@testing-library/react'; -import { toast as sonnerToast } from 'sonner'; +import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import { ToastVariety } from '~common/components/Toast'; -import { render } from '~common/helpers/test-utils'; +import { createToastTestState, render } from '~common/helpers/test-utils'; import { toast, ToastDuration } from '..'; +import { clearRepeatedToastTracking } from '../toast-internals/repeated-toast-tracking'; import { Button } from '../../components'; const TEST_MESSAGE = 'Test message'; const SUCCESS_MESSAGE = 'Success message'; const WARNING_DESCRIPTION = 'Warning description'; +const { resetToastTestState, trackToastId } = createToastTestState({ + cleanupTrackedToast: clearRepeatedToastTracking, +}); + describe('toast utility - basic functionality', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); it('should create and render a toast with required parameters', async () => { render(
); - toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - }); + trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); }); - it('should create a toast with all optional parameters, render actions and dismiss button', async () => { + it('should render optional fields, actions, and a dismiss button', async () => { const actions = jest.fn(() => ); const { container } = render(
); - toast({ - variety: ToastVariety.Success, - title: 'Success title', - description: SUCCESS_MESSAGE, - id: 'custom-id', - duration: ToastDuration.Infinite, - isDismissable: true, - screenReaderPrefix: 'Toast prefix', - actions, - }); + trackToastId( + toast({ + variety: ToastVariety.Success, + title: 'Success title', + description: SUCCESS_MESSAGE, + id: 'custom-id', + duration: ToastDuration.Infinite, + isDismissable: true, + screenReaderPrefix: 'Toast prefix', + actions, + }), + ); expect(await screen.findByText('Success title')).toBeInTheDocument(); expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument(); @@ -71,9 +77,7 @@ describe('toast utility - basic functionality', () => { }); describe('toast utility - variety shortcuts', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); const shortcutTests = [ { method: 'success', description: SUCCESS_MESSAGE, a11yPrefix: 'Success:' }, @@ -86,10 +90,12 @@ describe('toast utility - variety shortcuts', () => { it(`should create ${method} toast with correct variety and content`, async () => { render(
); - toast[method]({ - description, - title: `${method} title`, - }); + trackToastId( + toast[method]({ + description, + title: `${method} title`, + }), + ); expect(await screen.findByText(description)).toBeInTheDocument(); expect(screen.getByText(`${method} title`)).toBeInTheDocument(); @@ -99,22 +105,22 @@ describe('toast utility - variety shortcuts', () => { }); describe('toast utility - dismissal and interaction', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); it('should dismiss toast when dismiss button is clicked', async () => { const onAutoClose = jest.fn(); const onDismiss = jest.fn(); const { user } = render(
); - toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - isDismissable: true, - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + isDismissable: true, + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); @@ -129,12 +135,14 @@ describe('toast utility - dismissal and interaction', () => { const onDismiss = jest.fn(); render(
); - const toastId = toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - onAutoClose, - onDismiss, - }); + const toastId = trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); @@ -152,24 +160,26 @@ describe('toast utility - dismissal and interaction', () => { const { user } = render(
); - toast({ - variety: ToastVariety.Success, - description: actionToastMessage, - duration: ToastDuration.Infinite, - isDismissable: true, - actions: ({ dismiss }) => ( - - ), - - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Success, + description: actionToastMessage, + duration: ToastDuration.Infinite, + isDismissable: true, + actions: ({ dismiss }) => ( + + ), + + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(actionToastMessage)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Undo Action' })).toBeInTheDocument(); @@ -189,13 +199,15 @@ describe('toast utility - dismissal and interaction', () => { const onDismiss = jest.fn(); render(
); - toast({ - variety: ToastVariety.Warning, - description: WARNING_DESCRIPTION, - duration: ToastDuration.Short, - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Warning, + description: WARNING_DESCRIPTION, + duration: ToastDuration.Short, + onAutoClose, + onDismiss, + }), + ); act(() => { jest.advanceTimersByTime(2000); @@ -210,39 +222,212 @@ describe('toast utility - dismissal and interaction', () => { // It should auto-close after 8 seconds, we are at 6 seconds now act(() => { jest.advanceTimersByTime(4000); + jest.runOnlyPendingTimers(); }); - expect(screen.queryByText(WARNING_DESCRIPTION)).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText(WARNING_DESCRIPTION)).not.toBeInTheDocument(); + }); + expect(onAutoClose).toHaveBeenCalled(); expect(onDismiss).not.toHaveBeenCalled(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); }); }); describe('toast utility - stable id updates', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); it('should replace an existing toast when the same stable id is reused', async () => { render(
); - toast.info({ - description: 'Synchronizing repository settings...', - id: 'repository-sync', - }); + trackToastId( + toast.info({ + description: 'Synchronizing repository settings...', + id: 'repository-sync', + }), + ); expect(await screen.findByText('Synchronizing repository settings...')).toBeInTheDocument(); - toast.success({ - description: 'Repository settings synchronized.', - id: 'repository-sync', - title: 'Sync complete', - }); + trackToastId( + toast.success({ + description: 'Repository settings synchronized.', + id: 'repository-sync', + title: 'Sync complete', + }), + ); expect(await screen.findByText('Repository settings synchronized.')).toBeInTheDocument(); expect(screen.getByText('Sync complete')).toBeInTheDocument(); expect(screen.queryByText('Synchronizing repository settings...')).not.toBeInTheDocument(); }); + + it('should stop automatic aggregation immediately when a returned aggregated id is later reused explicitly', async () => { + render(
); + + const repeatedToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + }), + ); + + expect(await screen.findByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + + trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + }), + ); + + expect(await screen.findByText('Shown 2 times')).toBeInTheDocument(); + + trackToastId( + toast.info({ + description: 'Upload still in progress', + id: repeatedToastId, + }), + ); + + expect(await screen.findByText('Upload still in progress')).toBeInTheDocument(); + expect(screen.queryByText(SUCCESS_MESSAGE)).not.toBeInTheDocument(); + + const separateToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + }), + ); + + expect(separateToastId).not.toBe(repeatedToastId); + expect(await screen.findAllByText(SUCCESS_MESSAGE)).toHaveLength(1); + expect(screen.getByText('Upload still in progress')).toBeInTheDocument(); + expect(screen.queryByText(/^Shown \d+ times$/)).not.toBeInTheDocument(); + + toast.dismiss(repeatedToastId); + await waitForElementToBeRemoved(() => screen.queryByText('Upload still in progress')); + expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(/^Shown \d+ times$/)).not.toBeInTheDocument(); + }); + + it('should not reuse an auto-generated repeated-toast id after the previous toast is dismissed', async () => { + render(
); + + const firstToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + }), + ); + + expect(await screen.findByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + + toast.dismiss(firstToastId); + await waitForElementToBeRemoved(() => screen.queryByText(SUCCESS_MESSAGE)); + + const secondToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + }), + ); + + expect(secondToastId).not.toBe(firstToastId); + expect(await screen.findByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + + toast.dismiss(firstToastId); + expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + }); +}); + +describe('toast utility - automatic aggregation opt-outs', () => { + afterEach(resetToastTestState); + + it('should keep repeated toasts separate when onDismiss is provided', async () => { + const firstOnDismiss = jest.fn(); + const secondOnDismiss = jest.fn(); + render(
); + + const firstToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + onDismiss: firstOnDismiss, + }), + ); + + const secondToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + onDismiss: secondOnDismiss, + }), + ); + + expect(firstToastId).not.toBe(secondToastId); + + await waitFor(() => { + expect(screen.getAllByText(SUCCESS_MESSAGE)).toHaveLength(2); + }); + + toast.dismiss(firstToastId); + + await waitFor(() => { + expect(firstOnDismiss).toHaveBeenCalledTimes(1); + }); + + expect(secondOnDismiss).not.toHaveBeenCalled(); + + toast.dismiss(secondToastId); + + await waitFor(() => { + expect(secondOnDismiss).toHaveBeenCalledTimes(1); + }); + }); + + it('should keep repeated toasts separate when onAutoClose is provided', async () => { + jest.useFakeTimers(); + + const firstOnAutoClose = jest.fn(); + const secondOnAutoClose = jest.fn(); + render(
); + + const firstToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + duration: ToastDuration.Short, + onAutoClose: firstOnAutoClose, + }), + ); + + const secondToastId = trackToastId( + toast.success({ + description: SUCCESS_MESSAGE, + duration: ToastDuration.Short, + onAutoClose: secondOnAutoClose, + }), + ); + + expect(firstToastId).not.toBe(secondToastId); + + await waitFor(() => { + expect(screen.getAllByText(SUCCESS_MESSAGE)).toHaveLength(2); + }); + + act(() => { + jest.advanceTimersByTime(8000); + jest.runOnlyPendingTimers(); + }); + + act(() => { + // Sonner finalizes auto-dismiss on the next animation frame. + jest.advanceTimersToNextFrame(); + }); + + await waitFor(() => { + expect(firstOnAutoClose).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(secondOnAutoClose).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.queryAllByText(SUCCESS_MESSAGE)).toHaveLength(0); + }); + }); }); diff --git a/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx b/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx new file mode 100644 index 00000000..92623fb8 --- /dev/null +++ b/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx @@ -0,0 +1,295 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { type ToastId, ToastVariety } from '~common/components/Toast'; +import { clearRepeatedToastTracking, trackRepeatedToast } from '../repeated-toast-tracking'; + +const trackedToastIds = new Set(); + +const uploadCompleteToast = { + description: 'File uploaded', + title: 'Upload complete', + variety: ToastVariety.Success, +} as const; + +const descriptionOnlyUploadCompleteToast = { + description: 'File uploaded', + variety: ToastVariety.Success, +} as const; + +const uploadCompleteToastWithStringArrays = { + description: ['File', ' uploaded'], + title: ['Upload', ' complete'], + variety: ToastVariety.Success, +} as const; + +function rememberTrackedToast(trackedToastState: ReturnType) { + if (trackedToastState !== undefined) { + trackedToastIds.add(trackedToastState.id); + } + + return trackedToastState; +} + +afterEach(() => { + for (const toastId of trackedToastIds) { + clearRepeatedToastTracking(toastId); + } + + trackedToastIds.clear(); +}); + +describe('repeated toast tracking', () => { + it('should increment the count for repeated plain-text toasts with a title', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(firstToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const secondToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should normalize surrounding whitespace before matching repeated plain-text toasts', () => { + const firstToast = rememberTrackedToast( + trackRepeatedToast({ + description: ' File uploaded ', + title: ' Upload complete ', + variety: ToastVariety.Success, + }), + ); + + const secondToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should increment the count for repeated plain-text toasts without a title', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(descriptionOnlyUploadCompleteToast)); + + expect(firstToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const secondToast = rememberTrackedToast( + trackRepeatedToast(descriptionOnlyUploadCompleteToast), + ); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should use different synthetic ids when the variety differs', () => { + const firstToast = rememberTrackedToast( + trackRepeatedToast({ + description: 'Test message', + title: 'Notification', + variety: ToastVariety.Info, + }), + ); + + const secondToast = rememberTrackedToast( + trackRepeatedToast({ + description: 'Test message', + title: 'Notification', + variety: ToastVariety.Danger, + }), + ); + + expect(firstToast?.id).not.toBe(secondToast?.id); + expect(firstToast?.count).toBe(1); + expect(secondToast?.count).toBe(1); + }); + + it('should not track toasts with an explicit id, actions, or lifecycle callbacks', () => { + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + id: 'custom-toast-id', + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + actions: () => 'Undo action', + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + onAutoClose: jest.fn(), + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + onDismiss: jest.fn(), + }), + ).toBeUndefined(); + }); + + it('should treat blank plain-text titles as absent and still require visible plain-text content', () => { + const descriptionOnlyToast = rememberTrackedToast( + trackRepeatedToast(descriptionOnlyUploadCompleteToast), + ); + + expect(descriptionOnlyToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const blankTitleToast = rememberTrackedToast( + trackRepeatedToast({ + ...uploadCompleteToast, + title: '', + }), + ); + + expect(blankTitleToast).toEqual({ + count: 2, + id: descriptionOnlyToast?.id, + }); + + const whitespaceTitleToast = rememberTrackedToast( + trackRepeatedToast({ + ...uploadCompleteToast, + title: ' ', + }), + ); + + expect(whitespaceTitleToast).toEqual({ + count: 3, + id: descriptionOnlyToast?.id, + }); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + title: Upload complete, + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + description: '', + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + description: ' ', + }), + ).toBeUndefined(); + + expect(rememberTrackedToast(trackRepeatedToast(uploadCompleteToast))).toEqual({ + count: 1, + id: expect.any(String), + }); + }); + + it('should not track toasts with non-text descriptions', () => { + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + description: File uploaded, + }), + ).toBeUndefined(); + }); + + it('should track plain-text string array content', () => { + const firstToast = rememberTrackedToast( + trackRepeatedToast(uploadCompleteToastWithStringArrays), + ); + + expect(firstToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const secondToast = rememberTrackedToast( + trackRepeatedToast(uploadCompleteToastWithStringArrays), + ); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should not track non-array text iterables', () => { + expect( + trackRepeatedToast({ + description: new Set(['File', ' uploaded']), + title: new Set(['Upload', ' complete']), + variety: ToastVariety.Info, + }), + ).toBeUndefined(); + }); + + it('should not track string array content with non-text children', () => { + expect( + trackRepeatedToast({ + description: ['File', uploaded], + title: ['Upload', complete], + variety: ToastVariety.Info, + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + description: 'File uploaded', + title: ['Upload', complete], + variety: ToastVariety.Info, + }), + ).toBeUndefined(); + }); + + it('should reset the count with a fresh id after clearing a tracked toast', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + clearRepeatedToastTracking(firstToast!.id); + + const secondToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + expect(secondToast?.id).not.toBe(firstToast?.id); + }); +}); diff --git a/src/utils/toast-internals/repeated-toast-tracking.ts b/src/utils/toast-internals/repeated-toast-tracking.ts new file mode 100644 index 00000000..2a0f9747 --- /dev/null +++ b/src/utils/toast-internals/repeated-toast-tracking.ts @@ -0,0 +1,160 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { isValidElement } from 'react'; +import { type ToastId, type ToastProps } from '~common/components/Toast'; +import { isDefined } from '~common/helpers/types'; +import { type TextNodeOptional } from '~types/utils'; + +/** + * Figures out whether a new toast call should reuse an existing visible toast instead of creating + * a second one. + */ +interface RepeatedToastTrackingParams extends Pick< + ToastProps, + 'actions' | 'description' | 'title' | 'variety' +> { + id?: ToastId; + onAutoClose?: VoidFunction; + onDismiss?: VoidFunction; +} + +interface RepeatedToastTrackingState { + count: number; +} + +interface RepeatedToastTrackingStateWithId extends RepeatedToastTrackingState { + id: ToastId; +} + +const REPEATED_TOAST_ID_PREFIX = 'echoes-toast-auto::'; +// Keyed by the normalized aggregation key for the currently visible toast. +const repeatedToastStateByKey = new Map(); +const repeatedToastKeyById = new Map(); +let repeatedToastIdSequence = 0; + +export function trackRepeatedToast( + params: Readonly, +): RepeatedToastTrackingStateWithId | undefined { + const repeatedToastKey = getRepeatedToastKey(params); + + if (!isDefined(repeatedToastKey)) { + return undefined; + } + + const currentState = repeatedToastStateByKey.get(repeatedToastKey); + + const nextState = isDefined(currentState) + ? { + id: currentState.id, + count: currentState.count + 1, + } + : { + id: getNextRepeatedToastId(), + count: 1, + }; + + repeatedToastStateByKey.set(repeatedToastKey, nextState); + repeatedToastKeyById.set(nextState.id, repeatedToastKey); + + return nextState; +} + +export function clearRepeatedToastTracking(repeatedToastId: ToastId) { + const repeatedToastKey = repeatedToastKeyById.get(repeatedToastId); + + if (!isDefined(repeatedToastKey)) { + return; + } + + repeatedToastKeyById.delete(repeatedToastId); + repeatedToastStateByKey.delete(repeatedToastKey); +} + +export function isRepeatedToastId(toastId: ToastId | undefined): toastId is string { + return typeof toastId === 'string' && toastId.startsWith(REPEATED_TOAST_ID_PREFIX); +} + +function getRepeatedToastKey(params: Readonly): string | undefined { + if ( + isDefined(params.id) || + isDefined(params.actions) || + isDefined(params.onAutoClose) || + isDefined(params.onDismiss) + ) { + return undefined; + } + + // Automatic aggregation requires a visible plain-text description. A visible plain-text title, + // when present, also becomes part of the aggregation key. Blank plain-text titles are treated + // the same as no title. + const hasTitleProp = isDefined(params.title) && params.title !== false; + const titleTextValue = getToastPlainText(params.title)?.trim(); + const titleText = hasVisibleText(titleTextValue) ? titleTextValue : undefined; + const descriptionText = getToastPlainText(params.description)?.trim(); + + if (!hasVisibleText(descriptionText) || (hasTitleProp && !isDefined(titleTextValue))) { + return undefined; + } + + return `${REPEATED_TOAST_ID_PREFIX}${encodeURIComponent( + JSON.stringify([params.variety, titleText, descriptionText]), + )}`; +} + +function getNextRepeatedToastId() { + repeatedToastIdSequence += 1; + + return `${REPEATED_TOAST_ID_PREFIX}${repeatedToastIdSequence}`; +} + +function hasVisibleText(text: string | undefined): text is string { + return isDefined(text) && text.trim().length > 0; +} + +function getToastPlainText(node: TextNodeOptional | undefined): string | undefined { + if (!isDefined(node) || typeof node === 'boolean') { + return undefined; + } + + if (typeof node === 'string') { + return node; + } + + // Keep aggregation intentionally narrow: only strings and arrays of text are supported so we do + // not consume arbitrary iterables before React renders them. + if (isValidElement(node) || !Array.isArray(node)) { + return undefined; + } + + let text = ''; + + for (const child of node) { + const childText = getToastPlainText(child); + + if (!isDefined(childText)) { + return undefined; + } + + text += childText; + } + + return text; +} diff --git a/src/utils/toasts.tsx b/src/utils/toasts.tsx index 9037b088..5aef4df8 100644 --- a/src/utils/toasts.tsx +++ b/src/utils/toasts.tsx @@ -22,6 +22,11 @@ import { Ref } from 'react'; import { toast as sonnerToast } from 'sonner'; import { Toast, ToastId, ToastProps } from '~common/components/Toast'; import { isDefined } from '~common/helpers/types'; +import { + clearRepeatedToastTracking, + isRepeatedToastId, + trackRepeatedToast, +} from './toast-internals/repeated-toast-tracking'; export { ToastVariety } from '~common/components/Toast'; @@ -48,12 +53,13 @@ export enum ToastDuration { Infinite = 'infinite', } -export interface ToastParams extends Omit { +export interface ToastParams extends Omit { /** * Optional stable identifier for the toast. If not provided, one will be generated automatically. * This ID is used to manage the toast's lifecycle, such as dismissing it manually or updating it. * Reusing the same `id` is the supported way to intentionally replace or update the existing - * toast instead of creating a second one. + * toast instead of creating a second one. Providing an explicit `id` also bypasses automatic + * same-text toast aggregation, so the provided `id` is used as-is. */ id?: ToastId; /** @@ -62,11 +68,15 @@ export interface ToastParams extends Omit { */ duration?: `${ToastDuration}`; /** - * Callback function executed when the toast auto-closes due reaching the end of its duration (optional). + * Callback function executed when the toast auto-closes due to reaching the + * end of its duration (optional). Providing this callback opts the toast out + * of automatic same-text aggregation. */ onAutoClose?: VoidFunction; /** - * Callback function executed when the toast is manually dismissed (optional). + * Callback function executed when the toast is manually dismissed + * (optional). Providing this callback opts the toast out of automatic + * same-text aggregation. */ onDismiss?: VoidFunction; } @@ -84,21 +94,68 @@ function toastFn(params: ToastParams, ref?: Ref): ToastId { ...toastProps } = params; + if (isDefined(id) && isRepeatedToastId(id)) { + clearRepeatedToastTracking(id); + } + + // Repeated plain-text toasts of the same variety reuse a synthetic id so they stay as one + // visible toast with an incrementing counter instead of stacking duplicates. + const repeatedToastState = trackRepeatedToast(params); + const repeatedToastId = repeatedToastState?.id; + const repeatedToastCount = repeatedToastState?.count; + const durationValue = isDefined(toastProps.actions) ? TOAST_DURATION_MAP[ToastDuration.Infinite] : TOAST_DURATION_MAP[duration]; const isDismissableValue = durationValue === Infinity || isDismissable; + const visibleToastId = repeatedToastId ?? id; + const trackedRepeatedToastId = isRepeatedToastId(visibleToastId) ? visibleToastId : undefined; + + const clearRepeatedToastTrackingIfNeeded = () => { + if (!isDefined(trackedRepeatedToastId)) { + return; + } + + clearRepeatedToastTracking(trackedRepeatedToastId); + }; + + // Repeated toasts share the same id and count, but Sonner still owns their lifetime. + const handleToastAutoClose = + isDefined(trackedRepeatedToastId) || isDefined(onAutoClose) + ? () => { + clearRepeatedToastTrackingIfNeeded(); + + onAutoClose?.(); + } + : undefined; + + const handleToastDismiss = + isDefined(trackedRepeatedToastId) || isDefined(onDismiss) + ? () => { + clearRepeatedToastTrackingIfNeeded(); + + onDismiss?.(); + } + : undefined; return sonnerToast.custom( - (id) => , + (id) => ( + + ), { className, duration: durationValue, - onAutoClose, - onDismiss, + onAutoClose: handleToastAutoClose, + onDismiss: handleToastDismiss, // Passing id={undefined} breaks the dismiss functionality in Sonner - ...(isDefined(id) ? { id } : {}), + ...(isDefined(visibleToastId) ? { id: visibleToastId } : {}), }, ); } @@ -165,6 +222,19 @@ type ToastFn = { * Calling `toast` or one of the variety shortcuts again with that `id` updates the existing toast * instead of creating a second one. * + * **Aggregating repeated toasts** + * + * When no explicit `id` is provided, repeated calls with the same plain-text `description` and + * the same `variety` reuse the existing toast instead of creating duplicates. If the toast also + * has a plain-text `title`, that title must match too. The toast keeps its original text and + * shows a counter badge next to the title, or next to the description when there is no title. + * + * Toasts with actions, lifecycle callbacks, non-text descriptions, or non-text titles are not + * automatically aggregated. Blank plain-text titles are treated the same as no title. + * When automatic aggregation happens, non-key props from the latest matching call, such as + * `duration`, `isDismissable`, `className`, and `screenReaderPrefix`, are applied to the shared + * toast. + * * **Important Rules** * * - Toasts with actions must be dismissible and have infinite duration diff --git a/stories/Toast-stories.tsx b/stories/Toast-stories.tsx index 7dbdebd8..00db52fb 100644 --- a/stories/Toast-stories.tsx +++ b/stories/Toast-stories.tsx @@ -17,11 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -/* eslint-disable no-console */ +/* eslint-disable no-console */ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button, toast, type ToastParams } from '../src'; -import { Toast, ToastVariety } from '../src/common/components/Toast'; +import { ToastVariety } from '../src/common/components/Toast'; import { basicWrapperDecorator } from './helpers/BasicWrapper'; import { toDisabledControlArgType, toTextControlArgTypes } from './helpers/arg-types'; @@ -44,18 +44,78 @@ const meta: Meta = { duration: { control: { type: 'select' }, table: { - defaultValue: { summary: 'short' }, + defaultValue: { summary: 'medium' }, }, options: ['short', 'medium', 'long', 'infinite'], }, + isDismissable: { + control: { type: 'boolean' }, + table: { + defaultValue: { summary: 'false' }, + }, + }, }, decorators: [basicWrapperDecorator], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; +type ToastStoryArgs = NonNullable; + +const AGGREGATED_REPEATED_TOAST_CONTROLS_EXCLUDE = ['actions', 'id', 'onAutoClose', 'onDismiss']; + +function renderAggregatedRepeatedToasts(args: Story['args'], defaultTitle: ToastParams['title']) { + const resolvedArgs: ToastStoryArgs = args ?? {}; + + const { + actions: _ignoredActions, + description = 'File uploaded', + id: _ignoredId, + isDismissable = true, + onAutoClose: _ignoredOnAutoClose, + onDismiss: _ignoredOnDismiss, + title = defaultTitle, + variety = ToastVariety.Success, + ...restArgs + } = resolvedArgs; + + return ( + + ); +} export const Simple: Story = { args: { @@ -119,7 +179,8 @@ export const Shortcuts: Story = { return ( <> - The toast function provide shortcuts for each variety: + The toast function provides shortcuts for each variety: +
             {`toast.success({ description: 'Success toast message' });
@@ -128,6 +189,7 @@ toast.warning({ description: 'Warning toast message' });
 toast.error({ description: 'Error toast message' });`}
           
+