[Notifications] RTK Query API and Redux slice
>>> [!note] Migrated issue <!-- Drupal.org comment --> <!-- Migrated from issue #3580214. --> Reported by: [justafish](https://www.drupal.org/user/161058) Related to !838 >>> <p>Create the frontend data layer for the notification system: an RTK Query API service for fetching notifications and marking them as read, a Redux slice for UI-only state, adaptive polling logic, a notification sorting utility, and a timestamp formatting utility. This ticket provides the data foundation that the Activity Center (ticket 5) and toast system (ticket 6) build on.</p> <h2>Dependencies</h2> <p>Ticket 2 (backend API endpoints) must be complete.</p> <h2>Acceptance criteria</h2> <ul> <li><code>notificationsApi</code> RTK Query service is created with <code>getNotifications</code> and <code>markNotificationsRead</code> endpoints.</li> <li><code>getNotifications</code> polls at a client-determined interval: 2000ms when processing notifications are present in the cache, 10000ms otherwise.</li> <li>Polling slows down when the browser tab is not focused (300000ms / 5 min instead of pausing entirely).</li> <li><code>markNotificationsRead</code> sends <code>POST /canvas/api/v0/notifications/read</code> and optimistically updates the cached <code>hasRead</code> state.</li> <li><code>notificationsSlice</code> manages <code>pageOpenedAt</code> and <code>activityCenterOpen</code> state.</li> <li><code>pageOpenedAt</code> is initialized to the current timestamp when the store is created.</li> <li>Sorting utility orders notifications per FR-3: processing first, then unread errors (newest first), then unread warnings (newest first), then everything else chronologically (newest first).</li> <li>Badge count selector returns count of all unread notifications (FR-2).</li> <li>An <code>AUTO_READ_TYPES</code> constant defines types (<code>success</code>, <code>info</code>) that are automatically marked as read when the Activity Center is opened or a toast is dismissed.</li> <li>Timestamp formatting utility returns "Now", "Xm ago", "Xh ago", or "Xd ago" per FR-10.</li> <li>Both <code>notificationsApi</code> and <code>notificationsSlice</code> are registered in <code>store.ts</code>.</li> <li>Unit tests cover sorting, badge count, timestamp formatting, and slice reducers.</li> </ul> <h2>Implementation plan</h2> <h3>RTK Query API</h3> <p>Create <code>ui/src/services/notificationsApi.ts</code>:</p> <pre><pre>import { createApi } from '@reduxjs/toolkit/query/react';<br>import { baseQuery } from '@/services/baseQuery';<br><br>export interface NotificationAction {<br>&nbsp; label: string;<br>&nbsp; href: string;<br>}<br><br>export interface Notification {<br>&nbsp; id: string;<br>&nbsp; type: 'processing' | 'success' | 'info' | 'warning' | 'error';<br>&nbsp; key: string | null;<br>&nbsp; title: string;<br>&nbsp; message: string;<br>&nbsp; timestamp: number;<br>&nbsp; hasRead: boolean;<br>&nbsp; actions: NotificationAction[] | null;<br>}<br><br>interface NotificationsResponse {<br>&nbsp; data: {<br>&nbsp;&nbsp;&nbsp; notifications: Notification[];<br>&nbsp; };<br>}<br><br>export const notificationsApi = createApi({<br>&nbsp; reducerPath: 'notificationsApi',<br>&nbsp; baseQuery,<br>&nbsp; endpoints: (builder) =&gt; ({<br>&nbsp;&nbsp;&nbsp; getNotifications: builder.query&amp;lt;NotificationsResponse, void&amp;gt;({<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; query: () =&gt; '/canvas/api/v0/notifications',<br>&nbsp;&nbsp;&nbsp; }),<br>&nbsp;&nbsp;&nbsp; markNotificationsRead: builder.mutation&amp;lt;void, { ids: string[] }&amp;gt;({<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; query: (body) =&gt; ({<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; url: '/canvas/api/v0/notifications/read',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; method: 'POST',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; body,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }),<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; async onQueryStarted({ ids }, { dispatch, queryFulfilled }) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; // Optimistic update: mark notifications as read in cache immediately.<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const patch = dispatch(<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; notificationsApi.util.updateQueryData(<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'getNotifications',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; undefined,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; (draft) =&gt; {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; for (const notification of draft.data.notifications) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if (ids.includes(notification.id)) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; notification.hasRead = true;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; },<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ),<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; );<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; try {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await queryFulfilled;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; } catch {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; patch.undo();<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; },<br>&nbsp;&nbsp;&nbsp; }),<br>&nbsp; }),<br>});<br><br>export const {<br>&nbsp; useGetNotificationsQuery,<br>&nbsp; useMarkNotificationsReadMutation,<br>} = notificationsApi;</pre></pre><h3>Adaptive polling</h3> <p>Polling is fully client-determined based on two factors: whether processing notifications are present, and whether the browser tab is focused.</p> <pre><pre>const POLLING_FAST = 2000;<br>const POLLING_NORMAL = 10000;<br>const POLLING_BACKGROUND = 300000;<br><br>const isTabFocused = useDocumentVisibility(); // custom hook using document.visibilityState<br><br>const hasProcessingNotifications = data?.data.notifications.some(<br>&nbsp; (n) =&gt; n.type === 'processing',<br>) ?? false;<br><br>function getPollingInterval(): number {<br>&nbsp; if (!isTabFocused) return POLLING_BACKGROUND;<br>&nbsp; if (hasProcessingNotifications) return POLLING_FAST;<br>&nbsp; return POLLING_NORMAL;<br>}<br><br>const { data } = useGetNotificationsQuery(undefined, {<br>&nbsp; pollingInterval: getPollingInterval(),<br>});</pre></pre><table> <thead> <tr> <th>Tab state</th> <th>Processing present</th> <th>Interval</th> </tr> </thead> <tbody> <tr> <td>Focused</td> <td>Yes</td> <td>2000ms</td> </tr> <tr> <td>Focused</td> <td>No</td> <td>10000ms</td> </tr> <tr> <td>Unfocused</td> <td>Either</td> <td>300000ms (5 min)</td> </tr> </tbody> </table> <p>A small <code>useDocumentVisibility</code> hook wraps <code>document.visibilityState</code> with an event listener on <code>visibilitychange</code>, returning a boolean.</p> <h3>Redux slice</h3> <p>Create <code>ui/src/features/notifications/notificationsSlice.ts</code>:</p> <pre><pre>import { createSlice } from '@reduxjs/toolkit';<br>import type { PayloadAction } from '@reduxjs/toolkit';<br><br>interface NotificationsState {<br>&nbsp; pageOpenedAt: number;<br>&nbsp; activityCenterOpen: boolean;<br>}<br><br>const initialState: NotificationsState = {<br>&nbsp; pageOpenedAt: Date.now(),<br>&nbsp; activityCenterOpen: false,<br>};<br><br>export const notificationsSlice = createSlice({<br>&nbsp; name: 'notifications',<br>&nbsp; initialState,<br>&nbsp; reducers: {<br>&nbsp;&nbsp;&nbsp; setActivityCenterOpen(state, action: PayloadAction&amp;lt;boolean&amp;gt;) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; state.activityCenterOpen = action.payload;<br>&nbsp;&nbsp;&nbsp; },<br>&nbsp; },<br>&nbsp; selectors: {<br>&nbsp;&nbsp;&nbsp; selectActivityCenterOpen: (state) =&gt; state.activityCenterOpen,<br>&nbsp;&nbsp;&nbsp; selectPageOpenedAt: (state) =&gt; state.pageOpenedAt,<br>&nbsp; },<br>});<br><br>export const { setActivityCenterOpen } = notificationsSlice.actions;<br>export const { selectActivityCenterOpen, selectPageOpenedAt } =<br>&nbsp; notificationsSlice.selectors;</pre></pre><h3>Sorting utility</h3> <p>Create <code>ui/src/features/notifications/sortNotifications.ts</code>:</p> <pre><pre>import type { Notification } from '@/services/notificationsApi';<br><br>const TYPE_PRIORITY: Record&amp;lt;string, number&amp;gt; = {<br>&nbsp; processing: 0,<br>&nbsp; error: 1,<br>&nbsp; warning: 2,<br>};<br><br>export function sortNotifications(<br>&nbsp; notifications: Notification[],<br>): Notification[] {<br>&nbsp; return [...notifications].sort((a, b) =&gt; {<br>&nbsp;&nbsp;&nbsp; const aPriority = getEffectivePriority(a);<br>&nbsp;&nbsp;&nbsp; const bPriority = getEffectivePriority(b);<br>&nbsp;&nbsp;&nbsp; if (aPriority !== bPriority) return aPriority - bPriority;<br>&nbsp;&nbsp;&nbsp; return b.timestamp - a.timestamp;<br>&nbsp; });<br>}<br><br>function getEffectivePriority(n: Notification): number {<br>&nbsp; if (n.type === 'processing') return 0;<br>&nbsp; if (!n.hasRead &amp;amp;&amp;amp; n.type === 'error') return 1;<br>&nbsp; if (!n.hasRead &amp;amp;&amp;amp; n.type === 'warning') return 2;<br>&nbsp; return 3;<br>}</pre></pre><h3>Badge count selector</h3> <p>Create <code>ui/src/features/notifications/selectBadgeCount.ts</code>:</p> <pre><pre>import type { Notification } from '@/services/notificationsApi';<br><br>export function computeBadgeCount(notifications: Notification[]): number {<br>&nbsp; return notifications.filter((n) =&gt; !n.hasRead).length;<br>}</pre></pre><p>Only notifications of type <code>info</code>, <code>warning</code>, and <code>error</code> contribute to the badge count. <code>success</code> and <code>info</code> notifications are auto-marked as read when the Activity Center is opened or a toast is dismissed (see tickets 5 and 6).</p> <h3>Auto-read types constant</h3> <p>Create <code>ui/src/features/notifications/constants.ts</code>:</p> <pre>export const AUTO_READ_TYPES = new Set(['success', 'info']);</pre><p>This constant is shared by the Activity Center (ticket 5) and the toast manager (ticket 6) to determine which notification types are automatically marked as read on open/dismiss.</p> <h3>Timestamp formatting</h3> <p>Create <code>ui/src/features/notifications/formatTimestamp.ts</code>:</p> <pre><pre>export function formatTimestamp(timestamp: number): string {<br>&nbsp; const now = Date.now();<br>&nbsp; const diffMs = now - timestamp;<br>&nbsp; const diffMinutes = Math.floor(diffMs / 60000);<br>&nbsp; const diffHours = Math.floor(diffMs / 3600000);<br>&nbsp; const diffDays = Math.floor(diffMs / 86400000);<br><br>&nbsp; if (diffMinutes &amp;lt; 1) return 'Now';<br>&nbsp; if (diffMinutes &amp;lt; 60) return `${diffMinutes}m ago`;<br>&nbsp; if (diffHours &amp;lt; 24) return `${diffHours}h ago`;<br>&nbsp; return `${diffDays}d ago`;<br>}</pre></pre><h3>Store registration</h3> <p>In <code>ui/src/app/store.ts</code>:</p> <ul> <li>Import <code>notificationsApi</code> and <code>notificationsSlice</code>.</li> <li>Add both to <code>combineSlices()</code>.</li> <li>Add <code>notificationsApi.middleware</code> to the middleware chain.</li> </ul> <h2>Tests required</h2> <h3><code>ui/src/features/notifications/sortNotifications.test.ts</code></h3> <table> <thead> <tr> <th>Test case</th> <th>Validates</th> </tr> </thead> <tbody> <tr> <td>Processing notifications sort first</td> <td>FR-3.1</td> </tr> <tr> <td>Unread errors sort before unread warnings</td> <td>FR-3.2, FR-3.3</td> </tr> <tr> <td>Unread errors sort newest first</td> <td>FR-3.2</td> </tr> <tr> <td>Read errors drop to chronological group</td> <td>FR-3 rule</td> </tr> <tr> <td>Mixed types sort correctly</td> <td>Full priority chain</td> </tr> </tbody> </table> <h3><code>ui/src/features/notifications/selectBadgeCount.test.ts</code></h3> <table> <thead> <tr> <th>Test case</th> <th>Validates</th> </tr> </thead> <tbody> <tr> <td>Counts all unread notifications</td> <td>FR-2.1</td> </tr> <tr> <td>Includes unread info and success</td> <td>FR-2.2</td> </tr> <tr> <td>Includes unread processing</td> <td>FR-2.3</td> </tr> <tr> <td>Excludes read notifications</td> <td>FR-2.5</td> </tr> <tr> <td>Returns 0 when all read</td> <td>FR-2.4</td> </tr> </tbody> </table> <h3><code>ui/src/features/notifications/formatTimestamp.test.ts</code></h3> <table> <thead> <tr> <th>Test case</th> <th>Validates</th> </tr> </thead> <tbody> <tr> <td>Returns "Now" for &lt; 1 minute</td> <td>FR-10.1</td> </tr> <tr> <td>Returns "Xm ago" for &lt; 60 minutes</td> <td>FR-10.2</td> </tr> <tr> <td>Returns "Xh ago" for &lt; 24 hours</td> <td>FR-10.3</td> </tr> <tr> <td>Returns "Xd ago" for &gt;= 24 hours</td> <td>FR-10.4</td> </tr> </tbody> </table> <h3><code>ui/src/features/notifications/notificationsSlice.test.ts</code></h3> <table> <thead> <tr> <th>Test case</th> <th>Validates</th> </tr> </thead> <tbody> <tr> <td>Initial state has <code>pageOpenedAt</code> set</td> <td>Initialization</td> </tr> <tr> <td><code>setActivityCenterOpen</code> toggles state</td> <td>Reducer</td> </tr> </tbody> </table> <h2>Manual testing steps</h2> <p>No UI is built in this ticket. Verify by:</p> <ol> <li>Running <code>npm run type-check</code> in the <code>ui/</code> directory to confirm no type errors.</li> <li>Running <code>npm run test</code> to confirm all unit tests pass.</li> <li>Optionally, use Redux DevTools in the browser to inspect the <code>notificationsApi</code> and <code>notifications</code> slices after the store is registered.</li> </ol>
issue