fix(notifications): preserve action state and accessible recovery
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user