fix(notifications): preserve action state and accessible recovery

This commit is contained in:
cesnimda
2026-10-16 23:14:33 +02:00
parent 9dfea1226f
commit 40cfe04df5
22 changed files with 5963 additions and 66 deletions
+16 -2
View File
@@ -198,7 +198,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const [me, setMe] = useState<MeResponse | null>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [reminderCount, setReminderCount] = useState(0);
const [notificationCount, setNotificationCount] = useState(0);
const [notificationCount, setNotificationCount] = useState<number | undefined>(undefined);
const [notificationAnchor, setNotificationAnchor] = useState<HTMLElement | null>(null);
const path = location.pathname;
@@ -255,13 +255,27 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
return () => window.clearInterval(id);
}, []);
useEffect(() => {
let generation = 0;
let controller: AbortController | null = null;
const load = () => {
api.get<{ count: number }>("/notifications/unread-count").then((r) => setNotificationCount(Math.max(0, Number(r.data?.count) || 0))).catch(() => setNotificationCount(0));
controller?.abort();
const current = ++generation;
const request = new AbortController();
controller = request;
api.get<{ count: number }>("/notifications/unread-count", { signal: request.signal }).then((r) => {
if (request.signal.aborted || current !== generation) return;
const count = r.data?.count;
setNotificationCount(typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : undefined);
}).catch(() => {
if (!request.signal.aborted && current === generation) setNotificationCount(undefined);
});
};
load();
const id = window.setInterval(load, 60000);
window.addEventListener("notifications-changed", load);
return () => {
generation += 1;
controller?.abort();
window.clearInterval(id);
window.removeEventListener("notifications-changed", load);
};
@@ -1,12 +1,17 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { CssVarsProvider } from "@mui/material/styles";
import { I18nProvider } from "./i18n/I18nProvider";
import AppShell from "./layout/AppShell";
import { getTheme } from "./theme";
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
});
test("notification bell exposes its unread count and keyboard-accessible action", () => {
const open = jest.fn();
render(
@@ -32,6 +37,8 @@ test("notification bell exposes its unread count and keyboard-accessible action"
const bell = screen.getByRole("button", { name: "Notifications" });
expect(screen.getByText("3")).toBeInTheDocument();
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notifications: 3");
fireEvent.click(bell);
expect(open).toHaveBeenCalledTimes(1);
expect(open.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
@@ -102,3 +109,36 @@ test("shell icon controls expose accessible names", () => {
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "User" })).toBeInTheDocument();
});
function renderNotificationShell(count?: number) {
return render(<CssVarsProvider theme={getTheme("light") as any} defaultMode="light"><I18nProvider><AppShell pageTitle="Dashboard" breadcrumbs={["Home"]} pathname="/dashboard" nav={[]} navBottom={[]} onNavigate={() => undefined} onToggleDrawer={() => undefined} drawerOpen={false} notificationsCount={count}><div>Content</div></AppShell></I18nProvider></CssVarsProvider>);
}
test("[P7-08 notifications] an unavailable unread count stays unknown and its description follows the UI language", async () => {
renderNotificationShell();
const bell = screen.getByRole("button", { name: "Notifications" });
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notification count is unavailable.");
expect(bell).not.toHaveAccessibleDescription("Unread notifications: 0");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
expect(screen.getByRole("button", { name: "Varsler" })).toHaveAccessibleDescription("Antall uleste varsler er utilgjengelig.");
});
test("[P7-08 notifications] a recorded zero unread count has a truthful separate description", () => {
renderNotificationShell(0);
const bell = screen.getByRole("button", { name: "Notifications" });
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notifications: 0");
expect(bell).not.toHaveAccessibleDescription("Unread notification count is unavailable.");
});
test("[P7-08 notifications] the known unread count is described in Bokmål without changing the bell action's name", async () => {
localStorage.setItem("uiLanguage", "nb");
renderNotificationShell(3);
const bell = screen.getByRole("button", { name: "Varsler" });
expect(screen.getByText("3")).toBeInTheDocument();
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Uleste varsler: 3");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })); });
expect(screen.getByRole("button", { name: "Notifications" })).toHaveAccessibleDescription("Unread notifications: 3");
});
@@ -28,17 +28,21 @@ type Props = {
};
export default function NotificationsPopover({ anchorEl, onClose, onNavigate, onChanged }: Props) {
const { t } = useI18n();
const { language, t } = useI18n();
const open = Boolean(anchorEl);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ cause: unknown; action: boolean } | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const activeLoad = useRef<AbortController | null>(null);
const openGeneration = useRef(0);
const pendingAction = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const actionFocus = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
activeLoad.current?.abort();
if (activeLoad.current || pendingAction.current) return;
const controller = new AbortController();
activeLoad.current = controller;
setLoading(true);
@@ -48,11 +52,11 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setNotifications(response.data ?? []);
setError(null);
} catch (requestError) {
if (!controller.signal.aborted) setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
if (!controller.signal.aborted) setError({ cause: requestError, action: false });
} finally {
if (activeLoad.current === controller) setLoading(false);
if (activeLoad.current === controller) { activeLoad.current = null; setLoading(false); }
}
}, [t]);
}, []);
useEffect(() => {
openGeneration.current += 1;
@@ -63,31 +67,61 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setError(null);
setBusyId(null);
}
return () => activeLoad.current?.abort();
return () => {
openGeneration.current += 1;
activeLoad.current?.abort();
activeLoad.current = null;
pendingAction.current = false;
actionFocus.current = null;
};
}, [load, open]);
useEffect(() => {
if (!open || loading || busyId || !actionFocus.current) return;
const target = actionFocus.current;
actionFocus.current = null;
if (document.activeElement !== contentRef.current) return;
if (target.isConnected && !target.matches(":disabled")) target.focus();
else headingRef.current?.focus();
}, [busyId, loading, notifications, open]);
const rememberFocus = () => {
const active = document.activeElement;
if (active instanceof HTMLElement && active !== contentRef.current && contentRef.current?.contains(active)) {
actionFocus.current = active;
contentRef.current.focus();
}
};
const markRead = async (notification: UserNotification) => {
if (notification.readAtUtc || busyId) return;
if (notification.readAtUtc) return true;
if (pendingAction.current) return false;
pendingAction.current = true;
const generation = openGeneration.current;
rememberFocus();
setBusyId(notification.id);
try {
await api.post(`/notifications/${notification.id}/read`);
if (generation !== openGeneration.current) return;
if (generation !== openGeneration.current) return false;
setNotifications((items) => items.map((item) => item.id === notification.id
? { ...item, readAtUtc: new Date().toISOString() }
: item));
setError(null);
onChanged();
return true;
} catch (requestError) {
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError({ cause: requestError, action: true });
return false;
} finally {
if (generation === openGeneration.current) setBusyId(null);
if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); }
}
};
const dismiss = async (notification: UserNotification) => {
if (busyId) return;
if (pendingAction.current) return;
pendingAction.current = true;
const generation = openGeneration.current;
rememberFocus();
setBusyId(notification.id);
try {
await api.delete(`/notifications/${notification.id}`);
@@ -96,17 +130,18 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setError(null);
onChanged();
} catch (requestError) {
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError({ cause: requestError, action: true });
} finally {
if (generation === openGeneration.current) setBusyId(null);
if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); }
}
};
const openNotification = async (notification: UserNotification) => {
if (!notification.linkPath) return;
if (!notification.linkPath || pendingAction.current) return;
const generation = openGeneration.current;
if (!notification.readAtUtc) await markRead(notification);
if (!notification.readAtUtc && !await markRead(notification)) return;
if (generation !== openGeneration.current) return;
pendingAction.current = true;
onClose();
onNavigate(notification.linkPath);
};
@@ -120,20 +155,23 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
role: "dialog",
"aria-label": t("notificationsPanel"),
sx: {
mt: 1,
width: "min(420px, calc(100vw - 24px))",
maxHeight: "min(70vh, 600px)",
borderRadius: 3,
borderRadius: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5, flexShrink: 0 }}>
<Box>
<Typography component="h2" sx={{ fontWeight: 700 }}>{t("notifications")}</Typography>
<Typography component="h2" variant="sectionTitle" ref={headingRef} tabIndex={-1}>{t("notifications")}</Typography>
<Typography variant="caption" color="text.secondary">{t("notificationsRecent")}</Typography>
</Box>
<Tooltip title={t("close")}>
@@ -142,34 +180,33 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
</Stack>
<Divider />
<Box sx={{ overflowY: "auto", maxHeight: "min(52vh, 450px)", p: 1 }}>
<Box ref={contentRef} role="region" aria-label={t("notificationsRecent")} tabIndex={-1} aria-busy={loading} sx={{ overflowY: "auto", minHeight: 0, p: 1 }}>
{loading ? <Box role="status" sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
{error ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void load()}>{t("retry")}</Button>} sx={{ m: 0.5 }}>{error}</Alert> : null}
{error ? <Alert severity="error" action={<Button color="inherit" size="small" disabled={loading || busyId !== null} onClick={() => { rememberFocus(); void load(); }}>{t("retry")}</Button>} sx={{ m: 0.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(error.cause, t(error.action ? "notificationsActionFailed" : "notificationsLoadFailed"))}</Alert> : null}
{!loading && !error && notifications.length === 0 ? (
<Box sx={{ py: 4, px: 2, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800 }}>{t("notificationsEmptyTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("notificationsEmptyBody")}</Typography>
</Box>
) : null}
{!loading && !error ? notifications.map((notification) => (
{!loading ? notifications.map((notification) => (
<Box
key={notification.id}
sx={{
p: 1.5,
mb: 0.75,
borderRadius: 2.5,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
bgcolor: notification.readAtUtc ? "transparent" : "action.selected",
opacity: notification.readAtUtc ? 0.82 : 1,
}}
>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 700, overflowWrap: "anywhere" }}>{notification.title}</Typography>
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25, overflowWrap: "anywhere" }}>{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc)}</Typography>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap" }}>
{notification.linkPath ? <Button size="small" onClick={() => void openNotification(notification)}>{t("notificationsOpen")}</Button> : null}
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en-GB")}</Typography>
<Stack direction="row" useFlexGap spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap", "& .MuiButton-root": { whiteSpace: "normal", overflowWrap: "anywhere", minHeight: 40 } }}>
{notification.linkPath ? <Button size="small" disabled={busyId !== null} onClick={() => void openNotification(notification)}>{t("notificationsOpen")}</Button> : null}
{!notification.readAtUtc ? (
<Button size="small" startIcon={<DoneIcon />} disabled={busyId !== null} onClick={() => void markRead(notification)}>{t("notificationsMarkRead")}</Button>
) : null}
@@ -180,7 +217,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
</Box>
<Divider />
<Box sx={{ p: 1 }}>
<Box sx={{ p: 1, flexShrink: 0 }}>
<Button fullWidth onClick={() => { onClose(); onNavigate("/operations"); }}>{t("notificationsViewAll")}</Button>
</Box>
</Popover>
+6 -2
View File
@@ -245,6 +245,8 @@ export const translations = {
notificationsPanel: "Notifications panel",
notificationsRecent: "Recent updates and background work",
notificationsUnread: "Unread",
notificationsUnreadCount: "Unread notifications: {count}",
notificationsUnreadCountUnavailable: "Unread notification count is unavailable.",
notificationsLoadFailed: "Notifications could not be loaded.",
notificationsActionFailed: "The notification could not be updated.",
notificationsEmptyTitle: "You're all caught up",
@@ -252,7 +254,7 @@ export const translations = {
notificationsOpen: "Open",
notificationsMarkRead: "Mark read",
notificationsDismiss: "Dismiss",
notificationsViewAll: "View all activity",
notificationsViewAll: "View recent activity",
retry: "Retry",
quickSearch: "Quick Search",
searchPlaceholder: "Search jobs, companies, or actions",
@@ -2987,6 +2989,8 @@ export const translations = {
notificationsPanel: "Varslingspanel",
notificationsRecent: "Nylige oppdateringer og bakgrunnsarbeid",
notificationsUnread: "Ulest",
notificationsUnreadCount: "Uleste varsler: {count}",
notificationsUnreadCountUnavailable: "Antall uleste varsler er utilgjengelig.",
notificationsLoadFailed: "Varslene kunne ikke lastes.",
notificationsActionFailed: "Varslet kunne ikke oppdateres.",
notificationsEmptyTitle: "Du er ajour",
@@ -2994,7 +2998,7 @@ export const translations = {
notificationsOpen: "Åpne",
notificationsMarkRead: "Marker som lest",
notificationsDismiss: "Fjern",
notificationsViewAll: "Vis all aktivitet",
notificationsViewAll: "Vis nylig aktivitet",
retry: "Prøv igjen",
quickSearch: "Hurtigsøk",
searchPlaceholder: "Søk etter jobber, selskaper eller handlinger",
+10 -3
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
import { visuallyHidden } from "@mui/utils";
import {
AppBar,
@@ -135,6 +136,7 @@ export default function AppShell({
children: React.ReactNode;
}) {
const { language, setLanguage, t } = useI18n();
const notificationCountId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
@@ -361,6 +363,9 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<Box component="span" id={notificationCountId} role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{notificationsCount === undefined ? t("notificationsUnreadCountUnavailable") : t("notificationsUnreadCount", { count: notificationsCount })}
</Box>
<Box component="a" href="#app-main-content" sx={{ position: "fixed", top: 8, left: 8, zIndex: (theme) => theme.zIndex.tooltip + 1, px: 2, py: 1, borderRadius: 1, bgcolor: "background.paper", color: "text.primary", transform: "translateY(-160%)", "&:focus": { transform: "translateY(0)" } }}>
{t("skipToMainContent")}
</Box>
@@ -431,10 +436,11 @@ export default function AppShell({
size="small"
title={t("notifications")}
aria-label={t("notifications")}
aria-describedby={notificationCountId}
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99} slotProps={{ badge: { "aria-hidden": true } }}>
<NotificationsNoneIcon fontSize="small" />
</Badge>
</IconButton>
@@ -497,10 +503,11 @@ export default function AppShell({
size="small"
title={t("notifications")}
aria-label={t("notifications")}
aria-describedby={notificationCountId}
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99} slotProps={{ badge: { "aria-hidden": true } }}>
<NotificationsNoneIcon fontSize="small" />
</Badge>
</IconButton>
@@ -1,10 +1,11 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import NotificationsPopover from "./components/NotificationsPopover";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
jest.mock("./api", () => ({
api: {
@@ -28,6 +29,21 @@ const notification = {
readAtUtc: null,
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function copy(key: string, locale: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
return (translations[locale] as Record<string, string>)[key];
}
async function changeLanguage(locale: UiLanguage) {
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); });
}
function renderPopover(overrides: Partial<React.ComponentProps<typeof NotificationsPopover>> = {}) {
const anchor = document.createElement("button");
document.body.appendChild(anchor);
@@ -43,12 +59,16 @@ function renderPopover(overrides: Partial<React.ComponentProps<typeof Notificati
}
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
mockedApi.get.mockReset().mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockReset().mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockReset().mockResolvedValue({ data: {} } as any);
});
afterEach(() => jest.restoreAllMocks());
test("shows notifications and updates read and dismissed state in place", async () => {
const { props } = renderPopover();
@@ -115,3 +135,197 @@ test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
});
test.each([
["Open", "post"],
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] same-render duplicate %s starts one mutation", async (label, method) => {
const pending = deferred<any>();
mockedApi[method].mockImplementation(() => pending.promise);
const { props } = renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => { fireEvent.click(action); fireEvent.click(action); });
expect(mockedApi[method]).toHaveBeenCalledTimes(1);
expect(method === "post" ? mockedApi.delete : mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
if (label === "Open") {
expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations");
expect(props.onClose).toHaveBeenCalledTimes(1);
} else {
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
}
});
test("[P7-08 notifications] Open cannot navigate while another notification mutation is pending", async () => {
const pending = deferred<any>(); mockedApi.delete.mockImplementation(() => pending.promise);
const { props } = renderPopover();
const dismiss = await screen.findByRole("button", { name: "Dismiss" });
const open = screen.getByRole("button", { name: "Open" });
act(() => { fireEvent.click(dismiss); fireEvent.click(open); });
expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
});
test("[P7-08 notifications] failed Open retains its unread notification and GET Retry cannot repeat the write or navigate", async () => {
mockedApi.post.mockRejectedValueOnce(new Error("offline"));
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead") })).toBeEnabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onChanged).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("notificationsOpen") }));
await waitFor(() => expect(props.onNavigate).toHaveBeenCalledWith("/operations"));
expect(mockedApi.post).toHaveBeenCalledTimes(2); expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onClose).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] changing language during a held read does not reload or suppress its current successful Open", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByText(notification.title)).toBeVisible();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations"); expect(props.onClose).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] a held action failure uses the current language and preserves unread recovery without a locale refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Mark read" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => pending.reject(new Error("offline")));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "nb"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread", "nb"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead", "nb") })).toBeEnabled();
await changeLanguage("en");
expect(screen.getByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "en"));
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test("[P7-08 notifications] Bokmål notification dates follow selected UI language rather than browser default", async () => {
localStorage.setItem("uiLanguage", "nb");
const originalFormat = Date.prototype.toLocaleString;
const expected = originalFormat.call(new Date(notification.createdAtUtc), "nb-NO");
jest.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Date, ...args: Parameters<Date["toLocaleString"]>) {
return originalFormat.call(this, args[0] ?? "en-US", args[1]);
});
renderPopover();
expect(await screen.findByText(expected)).toBeVisible();
expect(screen.getByRole("heading", { name: copy("notifications", "nb") })).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] an older closed-panel write cannot mutate or unlock a reopened panel's pending Open", async () => {
const oldDismiss = deferred<any>(); const currentRead = deferred<any>();
mockedApi.delete.mockImplementationOnce(() => oldDismiss.promise); mockedApi.post.mockImplementation(() => currentRead.promise);
const { rerender, props, anchor } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Dismiss" }));
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
const current = { ...notification, id: "33333333-3333-4333-8333-333333333333", title: "Reopened panel notification", linkPath: "/jobs/42" };
mockedApi.get.mockResolvedValue({ data: [current] } as any);
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${current.id}/read`);
await act(async () => oldDismiss.resolve({ data: {} }));
expect(screen.getByText(current.title)).toBeVisible();
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await act(async () => currentRead.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/jobs/42");
expect(props.onClose).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test("[P7-08 notifications] an unmounted pending Open cannot publish changes, close, navigate or refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { unmount, props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
unmount();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("[P7-08 notifications] same-render GET Retry is single-flight and cannot issue a notification write", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
const { props } = renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test.each([
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] removing the focused %s action leaves focus on a meaningful panel target", async (label, method) => {
const pending = deferred<any>(); mockedApi[method].mockImplementation(() => pending.promise);
renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => action.focus()); expect(action).toHaveFocus();
fireEvent.click(action);
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument());
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(action);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
});
test("[P7-08 notifications] completing an action preserves deliberate focus on Close", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
renderPopover();
const markRead = await screen.findByRole("button", { name: "Mark read" });
act(() => markRead.focus()); fireEvent.click(markRead);
const close = screen.getByRole("button", { name: copy("close") });
act(() => close.focus()); expect(close).toHaveFocus();
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument());
expect(close).toHaveFocus(); expect(screen.getByText(notification.title)).toBeVisible();
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] successful focused GET Retry leaves focus on a meaningful panel target after its notice disappears", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => retry.focus()); expect(retry).toHaveFocus();
fireEvent.click(retry);
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(retry);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});