[Notifications] Toast notifications
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3580217. -->
Reported by: [justafish](https://www.drupal.org/user/161058)
Related to !861
>>>
<p>Implement toast notifications that appear when new notifications arrive. Toasts show in the top-right corner with type-specific styling and auto-dismiss after 15 seconds. An explicit dismiss marks the notification as read; an auto-dismiss does not. Only notifications that arrive after the page was opened trigger toasts.</p>
<h2>Dependencies</h2>
<ul>
<li>Ticket 4 (frontend state and API) must be complete.</li>
<li>Ticket 5 (Activity Center UI) should be complete or in progress (shares styling patterns with <code>NotificationCard</code>).</li>
</ul>
<h2>Acceptance criteria</h2>
<h3>Toast display (FR-5)</h3>
<ul>
<li>When new notifications arrive (<code>timestamp > pageOpenedAt</code>), a toast is shown for each new notification.</li>
<li>Toasts appear in the top-right corner of the viewport (FR-5.3).</li>
<li>Each toast displays the notification title, message, and action links (FR-5.4).</li>
<li>Toast styling matches the notification type (same color border and background as Activity Center cards per FR-6).</li>
<li>Toasts auto-dismiss after 15 seconds (FR-5.1).</li>
<li>Toasts can be dismissed explicitly by clicking X (FR-5.2).</li>
</ul>
<h3>Toast interaction (FR-5.5, FR-5.6, FR-9.5)</h3>
<ul>
<li>Clicking X to dismiss a toast marks the notification as read.</li>
<li>Clicking an action button marks the notification as read AND dismisses the toast (FR-5.6, FR-9.5). The action opens in a new tab (<code>window.open</code> with <code>noopener,noreferrer</code>).</li>
<li>The same notification appears in both the toast and the Activity Center (FR-5.7).</li>
</ul>
<h3>Feature flag</h3>
<p>Toasts only render when <code>drupalSettings.canvas.devMode</code> is <code>true</code> (same gate as the Activity Center).</p>
<h2>Implementation plan</h2>
<h3>Approach</h3>
<p>Use a fixed-position stack of custom toast components (React state in <code>NotificationToastManager</code>), not Sonner. The existing Sonner <code>Toaster</code> in <code>Toast.tsx</code> remains bottom-center for the saving overlay only. Polling for notifications is configured on <code>NotificationBell</code> (<code>useGetNotificationsQuery</code> with <code>skip: !devMode</code> and a dynamic <code>pollingInterval</code>); <code>NotificationToastManager</code> subscribes to the same RTK Query cache and does not define polling itself.</p>
<h3>File structure</h3>
<pre><pre>ui/src/features/notifications/<br> constants.ts -- TOAST_DURATION (15000 ms), shared polling constants<br> NotificationToastManager.tsx -- watches cache, visible toasts, timers, mark read on dismiss/action<br> NotificationToastManager.module.css -- fixed top-right container<br> NotificationToast.tsx -- presentational toast<br> NotificationToast.module.css -- toast-specific styles</pre></pre><h3>NotificationToastManager component</h3>
<p>This is mounted at the app level (in <code>App.tsx</code>), gated behind <code>devMode</code>. It reads the RTK Query notifications result and maintains <code>visibleToasts</code> plus <code>shownIds</code> for deduplication; auto-dismiss uses <code>setTimeout</code> for <code>TOAST_DURATION</code> and does not call <code>markRead</code>.</p>
<pre><pre>// Rough structure -- not final code.<br>const NotificationToastManager = () => {<br> const { data } = useGetNotificationsQuery();<br> const [markRead] = useMarkNotificationsReadMutation();<br> const pageOpenedAt = useAppSelector(selectPageOpenedAt);<br> const shownIds = useRef(new Set<string>());<br> const [visibleToasts, setVisibleToasts] = useState<Notification[]>([]);<br> // timers ref, dismissToast, handleDismiss (markRead + remove), handleAction (markRead + remove + window.open)<br> // useEffect: push new items to visibleToasts; start auto-dismiss timer (no markRead)<br> // render: fixed container mapping visibleToasts to <NotificationToast /><br> return null; // or the container when visibleToasts.length > 0<br>};</pre></pre><h3>NotificationToast component</h3>
<p>A styled card similar to <code>NotificationCard</code> but adapted for the toast context: same background color, left border, and icon as Activity Center cards. Parent passes <code>onDismiss</code> and <code>onAction</code>.</p>
<pre><pre>const NotificationToast = ({<br> notification,<br> onDismiss,<br> onAction,<br>}: {<br> notification: Notification;<br> onDismiss: (id: string) => void;<br> onAction: (id: string, href: string) => void;<br>}) => {<br> const handleDismiss = () => {<br> onDismiss(notification.id);<br> };<br><br> const handleAction = (action: NotificationAction) => {<br> onAction(notification.id, action.href);<br> };<br><br> return (<br> <div className={styles.toast} data-type={notification.type}><br> {/* icon, title, message */}<br> {notification.actions && (<br> <div className={styles.actions}><br> {notification.actions.map((action) => (<br> <button key={action.href} type="button" onClick={() => handleAction(action)}><br> {action.label}<br> </button><br> ))}<br> </div><br> )}<br> <button type="button" onClick={handleDismiss} aria-label="Dismiss notification"><br> <Cross2Icon /><br> </button><br> </div><br> );<br>};</pre></pre><h3>Toaster setup</h3>
<p>No second Sonner <code>Toaster</code> for notifications. The existing <code>Toast</code> component (bottom-center, for the saving overlay) remains unchanged.</p>
<h3>App-level mounting</h3>
<p>In <code>App.tsx</code>, conditionally render the toast manager:</p>
<pre>{getCanvasSettings()?.devMode && <NotificationToastManager />}</pre><h2>Tests required</h2>
<h3>Component tests (Vitest + React Testing Library)</h3>
<h4><code>ui/src/features/notifications/NotificationToastManager.test.tsx</code></h4>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Does not show notifications older than <code>pageOpenedAt</code></td>
<td>Timestamp filtering</td>
</tr>
<tr>
<td>Shows new notifications that arrive after <code>pageOpenedAt</code></td>
<td>New notification detection</td>
</tr>
<tr>
<td>Does not re-show a previously shown notification after dismiss + rerender</td>
<td>Deduplication via <code>shownIds</code></td>
</tr>
<tr>
<td>Auto-dismiss after 15 seconds removes the toast (without <code>markRead</code>)</td>
<td>Timer behavior</td>
</tr>
<tr>
<td>Explicit dismiss calls <code>markRead</code></td>
<td>FR-5.5</td>
</tr>
<tr>
<td><code>devMode</code> gating</td>
<td>Tested at <code>App</code> mount (manager is not skipped inside RTK <code>skip</code>)</td>
</tr>
</tbody>
</table>
<h4><code>ui/src/features/notifications/NotificationToast.test.tsx</code></h4>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Renders title and message</td>
<td>FR-5.4</td>
</tr>
<tr>
<td>Sets <code>data-type</code> per notification type (including processing spin)</td>
<td>FR-6 styling hook</td>
</tr>
<tr>
<td>Renders action buttons without pipe separators</td>
<td>FR-5.4</td>
</tr>
<tr>
<td>Clicking dismiss calls <code>onDismiss</code> with notification id</td>
<td>FR-5.2</td>
</tr>
<tr>
<td>Clicking action calls <code>onAction</code> with id and href</td>
<td>FR-5.6</td>
</tr>
</tbody>
</table>
<h3>Playwright E2E tests</h3>
<p>Optional: add to <code>tests/src/Playwright/notifications.spec.ts</code> (or alongside ticket 5 tests) when implemented. Not required for the initial Vitest coverage above.</p>
<p>These tests can live under a separate <code>test.describe('Toast notifications', ...)</code> block. They exercise the full toast flow end-to-end against a real Drupal backend. Notifications are seeded via <code>drush php-eval</code> calls to <code>CanvasNotificationHandler::create()</code>.</p>
<p>Reuses the same <code>beforeAll</code> and <code>beforeEach</code> setup from ticket 5 (installing <code>canvas</code>, <code>canvas_dev_mode</code>, <code>canvas_e2e_support</code>, and logging in as admin).</p>
<h4>Seeding notifications after page load</h4>
<p>To trigger toasts, notifications must be created after <code>pageOpenedAt</code>. Tests should navigate to the Canvas editor and wait for the UI to load, seed notifications via Drush in a separate step, then wait for the polling cycle using <code>page.waitForResponse</code> on the <code>GET /canvas/api/v0/notifications</code> endpoint or <code>expect.poll</code>.</p>
<h4>Test cases</h4>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Description</th>
<th>Key assertions</th>
</tr>
</thead>
<tbody>
<tr>
<td>Toast appears for new notification</td>
<td>Navigate to editor. Seed an info notification. Wait for poll.</td>
<td>Toast is visible in viewport with correct title and message.</td>
</tr>
<tr>
<td>Toast does not appear for old notification</td>
<td>Seed a notification with a past timestamp (before page load).</td>
<td>No toast appears. Notification is visible only in the Activity Center.</td>
</tr>
<tr>
<td>Toast has correct type styling</td>
<td>Seed one error and one warning notification.</td>
<td>Error toast has red border/background. Warning toast has amber border/background.</td>
</tr>
<tr>
<td>Dismiss marks as read</td>
<td>Seed an error notification. Wait for toast. Click X.</td>
<td>Toast disappears. Open Activity Center — notification shows read state. Badge count decrements.</td>
</tr>
<tr>
<td>Dismiss marks as read (info type)</td>
<td>Seed an info notification. Wait for toast. Click X.</td>
<td>Toast disappears. Open Activity Center — notification shows read state. Consistent with error dismiss.</td>
</tr>
<tr>
<td>Action click marks as read and opens link</td>
<td>Seed a notification with an action link. Wait for toast. Click the action.</td>
<td>Toast disappears. Notification is marked as read. New tab or navigation per <code>window.open</code> behavior.</td>
</tr>
<tr>
<td>Toast deduplication</td>
<td>Seed a notification. Wait for toast. Trigger another poll cycle.</td>
<td>The same notification does not produce a second toast.</td>
</tr>
<tr>
<td>Multiple toasts appear</td>
<td>Seed 3 notifications at once. Wait for poll.</td>
<td>3 toasts appear simultaneously.</td>
</tr>
<tr>
<td>Toast and Activity Center show same notification</td>
<td>Seed a notification. Wait for toast. Open Activity Center.</td>
<td>The same notification is visible in both the toast and the Activity Center list.</td>
</tr>
<tr>
<td>Feature flag gating</td>
<td>Uninstall <code>canvas_dev_mode</code>. Seed a notification.</td>
<td>No toast appears.</td>
</tr>
</tbody>
</table>
<p><strong>Notes:</strong> Tests should use <code>page.waitForResponse</code> or <code>expect.poll</code> to handle the async polling cycle rather than relying on arbitrary timeouts. Auto-dismiss (15s) is covered in Vitest with fake timers; E2E can skip long waits. For the action test, use an internal Drupal path (e.g. <code>/admin/reports/dblog</code>) so the test stays within the test site.</p>
<h2>Manual testing steps</h2>
<ol>
<li>Enable <code>canvas_dev_mode</code>. Open Canvas UI.</li>
<li>Note the current time (this is approximately <code>pageOpenedAt</code>).</li>
<li>
<p>In another terminal, create a new notification via Drush:</p>
<pre><pre>drush php:eval "<br> \Drupal::service(\Drupal\canvas\CanvasNotificationHandler::class)->create([<br> 'type' => 'info',<br> 'title' => 'New update available',<br> 'message' => 'A new version is available for your site.',<br> ]);<br>"</pre></pre></li>
<li>Wait for the next polling cycle. Verify:
<ul>
<li>A toast appears in the top-right corner.</li>
<li>The toast displays the title and message.</li>
</ul>
</li>
<li>Wait 15 seconds. Verify the toast auto-dismisses.</li>
<li>Open the Activity Center. Verify the notification is still <strong>unread</strong> (auto-dismiss does not mark as read).</li>
<li>Create another notification (any type). When the toast appears, click the X button. Verify:
<ul>
<li>The toast is dismissed.</li>
<li>The notification is marked as read in the Activity Center.</li>
</ul>
</li>
<li>
<p>Create a notification with actions:</p>
<pre><pre>drush php:eval "<br> \Drupal::service(\Drupal\canvas\CanvasNotificationHandler::class)->create([<br> 'type' => 'error',<br> 'title' => 'Sync failed',<br> 'message' => 'Unable to connect to server.',<br> 'actions' => [<br> ['label' => 'View logs', 'href' => '/admin/reports/dblog'],<br> ],<br> ]);<br>"</pre></pre><p>When the toast appears, click the "View logs" action link. Verify:</p>
<ul>
<li>The toast is dismissed.</li>
<li>The notification is marked as read in the Activity Center.</li>
<li>The link opens in a new tab (or per browser policy).</li>
</ul>
</li>
<li>Create a notification with a timestamp in the past (before <code>pageOpenedAt</code>). Verify no toast appears for it (it should only show in the Activity Center).</li>
<li>Test with multiple notification types (<code>success</code>, <code>warning</code>, <code>processing</code>). Verify each toast has the correct color styling.</li>
<li>Disable <code>canvas_dev_mode</code>. Reload. Create a notification. Verify no toasts appear.</li>
</ol>
issue