[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 &gt; 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>&nbsp; constants.ts&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -- TOAST_DURATION (15000 ms), shared polling constants<br>&nbsp; NotificationToastManager.tsx&nbsp;&nbsp;&nbsp; -- watches cache, visible toasts, timers, mark read on dismiss/action<br>&nbsp; NotificationToastManager.module.css&nbsp; -- fixed top-right container<br>&nbsp; NotificationToast.tsx&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -- presentational toast<br>&nbsp; NotificationToast.module.css&nbsp;&nbsp;&nbsp; -- 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 = () =&gt; {<br>&nbsp; const { data } = useGetNotificationsQuery();<br>&nbsp; const [markRead] = useMarkNotificationsReadMutation();<br>&nbsp; const pageOpenedAt = useAppSelector(selectPageOpenedAt);<br>&nbsp; const shownIds = useRef(new Set&lt;string&gt;());<br>&nbsp; const [visibleToasts, setVisibleToasts] = useState&lt;Notification[]&gt;([]);<br>&nbsp; // timers ref, dismissToast, handleDismiss (markRead + remove), handleAction (markRead + remove + window.open)<br>&nbsp; // useEffect: push new items to visibleToasts; start auto-dismiss timer (no markRead)<br>&nbsp; // render: fixed container mapping visibleToasts to &lt;NotificationToast /&gt;<br>&nbsp; return null; // or the container when visibleToasts.length &gt; 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>&nbsp; notification,<br>&nbsp; onDismiss,<br>&nbsp; onAction,<br>}: {<br>&nbsp; notification: Notification;<br>&nbsp; onDismiss: (id: string) =&gt; void;<br>&nbsp; onAction: (id: string, href: string) =&gt; void;<br>}) =&gt; {<br>&nbsp; const handleDismiss = () =&gt; {<br>&nbsp;&nbsp;&nbsp; onDismiss(notification.id);<br>&nbsp; };<br><br>&nbsp; const handleAction = (action: NotificationAction) =&gt; {<br>&nbsp;&nbsp;&nbsp; onAction(notification.id, action.href);<br>&nbsp; };<br><br>&nbsp; return (<br>&nbsp;&nbsp;&nbsp; &lt;div className={styles.toast} data-type={notification.type}&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {/* icon, title, message */}<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {notification.actions &amp;&amp; (<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;div className={styles.actions}&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {notification.actions.map((action) =&gt; (<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;button key={action.href} type="button" onClick={() =&gt; handleAction(action)}&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {action.label}<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;/button&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ))}<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;/div&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )}<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;button type="button" onClick={handleDismiss} aria-label="Dismiss notification"&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;Cross2Icon /&gt;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &lt;/button&gt;<br>&nbsp;&nbsp;&nbsp; &lt;/div&gt;<br>&nbsp; );<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 &amp;&amp; &lt;NotificationToastManager /&gt;}</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 &mdash; 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 &mdash; 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>&nbsp; \Drupal::service(\Drupal\canvas\CanvasNotificationHandler::class)-&gt;create([<br>&nbsp;&nbsp;&nbsp; 'type' =&gt; 'info',<br>&nbsp;&nbsp;&nbsp; 'title' =&gt; 'New update available',<br>&nbsp;&nbsp;&nbsp; 'message' =&gt; 'A new version is available for your site.',<br>&nbsp; ]);<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>&nbsp; \Drupal::service(\Drupal\canvas\CanvasNotificationHandler::class)-&gt;create([<br>&nbsp;&nbsp;&nbsp; 'type' =&gt; 'error',<br>&nbsp;&nbsp;&nbsp; 'title' =&gt; 'Sync failed',<br>&nbsp;&nbsp;&nbsp; 'message' =&gt; 'Unable to connect to server.',<br>&nbsp;&nbsp;&nbsp; 'actions' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ['label' =&gt; 'View logs', 'href' =&gt; '/admin/reports/dblog'],<br>&nbsp;&nbsp;&nbsp; ],<br>&nbsp; ]);<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