fix(operations): preserve task state and accessible recovery
This commit is contained in:
@@ -429,6 +429,19 @@ export const translations = {
|
||||
operations: "Operations",
|
||||
refresh: "Refresh",
|
||||
operationsSubtitle: "Background work survives navigation and refresh.",
|
||||
operationsActivity: "Recent activity",
|
||||
operationsRecentHelp: "Shows up to 50 recent notifications and 50 recent operations.",
|
||||
operationsCommittedRefreshFailed: "The action completed, but recent activity could not be refreshed. Refresh before taking another action.",
|
||||
operationsCreated: "Created {date}",
|
||||
operationsTaskCv: "CV processing",
|
||||
operationsTaskStrategy: "Strategy snapshot",
|
||||
operationsStatusQueued: "Queued",
|
||||
operationsStatusRunning: "Running",
|
||||
operationsStatusWaitingForRetry: "Waiting to retry",
|
||||
operationsStatusWaitingForFallback: "Waiting for approved fallback",
|
||||
operationsStatusSucceeded: "Completed",
|
||||
operationsStatusFailed: "Failed",
|
||||
operationsStatusCancelled: "Cancelled",
|
||||
operationsLoadFailed: "Operations could not be loaded.",
|
||||
operationsActionFailed: "The action could not be completed.",
|
||||
operationsLoading: "Loading operations",
|
||||
@@ -3173,6 +3186,19 @@ export const translations = {
|
||||
operations: "Operasjoner",
|
||||
refresh: "Oppdater",
|
||||
operationsSubtitle: "Bakgrunnsarbeid fortsetter når du navigerer eller oppdaterer siden.",
|
||||
operationsActivity: "Nylig aktivitet",
|
||||
operationsRecentHelp: "Viser opptil 50 nylige varsler og 50 nylige operasjoner.",
|
||||
operationsCommittedRefreshFailed: "Handlingen er fullført, men nylig aktivitet kunne ikke oppdateres. Oppdater før du utfører en ny handling.",
|
||||
operationsCreated: "Opprettet {date}",
|
||||
operationsTaskCv: "CV-behandling",
|
||||
operationsTaskStrategy: "Strategioversikt",
|
||||
operationsStatusQueued: "I kø",
|
||||
operationsStatusRunning: "Pågår",
|
||||
operationsStatusWaitingForRetry: "Venter på nytt forsøk",
|
||||
operationsStatusWaitingForFallback: "Venter på godkjent reservevalg",
|
||||
operationsStatusSucceeded: "Fullført",
|
||||
operationsStatusFailed: "Mislyktes",
|
||||
operationsStatusCancelled: "Avbrutt",
|
||||
operationsLoadFailed: "Operasjonene kunne ikke lastes.",
|
||||
operationsActionFailed: "Handlingen kunne ikke fullføres.",
|
||||
operationsLoading: "Laster operasjoner",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import OperationsPage from "./views/OperationsPage";
|
||||
import { translations } from "./i18n/translations";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
@@ -43,20 +44,45 @@ const notification = {
|
||||
readAtUtc: null,
|
||||
};
|
||||
|
||||
let serverOperations: any[];
|
||||
let serverNotifications: any[];
|
||||
function deferred<T = any>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
async function language(value: "en" | "nb") {
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: value })));
|
||||
}
|
||||
function response(url: string) { return { data: url.startsWith("/operations") ? serverOperations : serverNotifications } as any; }
|
||||
function activity() { return screen.getByRole("region", { name: "Recent activity" }); }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
|
||||
data: url.startsWith("/operations") ? [operation] : [notification],
|
||||
} as any));
|
||||
mockedApi.post.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: {} } as any);
|
||||
document.documentElement.lang = "en";
|
||||
serverOperations = [{ ...operation }]; serverNotifications = [{ ...notification }];
|
||||
mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
mockedApi.post.mockReset().mockImplementation((url: string) => {
|
||||
if (url.startsWith("/operations")) {
|
||||
serverOperations = serverOperations.map(item => ({ ...item, status: url.endsWith("/cancel") ? "cancelled" : "queued", canCancel: url.endsWith("/retry"), canRetry: url.endsWith("/cancel") }));
|
||||
return Promise.resolve({ data: serverOperations[0] } as any);
|
||||
}
|
||||
serverNotifications = serverNotifications.map(item => ({ ...item, readAtUtc: "2026-08-02T12:06:00Z" }));
|
||||
return Promise.resolve({ data: undefined } as any);
|
||||
});
|
||||
mockedApi.delete.mockReset().mockImplementation(() => { serverNotifications = []; return Promise.resolve({ data: undefined } as any); });
|
||||
});
|
||||
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); });
|
||||
|
||||
test("shows persistent operation and notification states with accessible actions", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
|
||||
const task = screen.getByRole("article", { name: operation.taskType });
|
||||
expect(task).toHaveAccessibleName(operation.taskType);
|
||||
expect(task).toHaveAttribute("data-operation-id", operation.id);
|
||||
expect(screen.getByText("Analysing role")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar", { name: "strategy_snapshot progress" })).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(screen.getByText("Operation failed")).toBeInTheDocument();
|
||||
@@ -85,6 +111,8 @@ test("shows honest empty and failure states", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||
renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("localizes operation controls and empty states in Norwegian Bokmål", async () => {
|
||||
@@ -97,3 +125,137 @@ test("localizes operation controls and empty states in Norwegian Bokmål", async
|
||||
expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] initial failure offers single-flight GET Retry without false empty or writes", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("offline")); renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect((mockedApi.get.mock.calls[0][1]?.signal as AbortSignal).aborted).toBe(true);
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
|
||||
const retry = screen.getByRole("button", { name: "Retry" }); act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(4); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
await act(async () => pending.resolve({ data: [] })); expect(await screen.findByText("No notifications.")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "Recent activity", level: 2 })).toHaveFocus(); expect(activity()).toHaveAttribute("aria-busy", "false");
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/operations?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test("[P7-08 operations] cached refresh failure preserves known rows and does not fabricate empty results", async () => {
|
||||
renderPage(); expect(await screen.findByText(operation.taskType)).toBeVisible(); mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect(screen.getByText(operation.taskType)).toBeVisible(); expect(screen.getByText(notification.title)).toBeVisible();
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["Cancel", "Retry", "Mark read", "Dismiss"])("[P7-08 operations] same-render duplicate %s reserves exactly one mutation and blocks competing work", async label => {
|
||||
if (label === "Retry") serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
const pending = deferred(); mockedApi.post.mockImplementation(() => pending.promise); mockedApi.delete.mockImplementation(() => pending.promise);
|
||||
renderPage(); const button = await screen.findByRole("button", { name: label });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); fireEvent.click(screen.getByRole("button", { name: label === "Dismiss" ? "Mark read" : "Dismiss" })); });
|
||||
expect(mockedApi.post.mock.calls.length + mockedApi.delete.mock.calls.length).toBe(1); expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||
await act(async () => pending.resolve({ data: label === "Cancel" || label === "Retry" ? { ...serverOperations[0], canCancel: false, canRetry: false } : undefined }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
|
||||
});
|
||||
|
||||
test("[P7-08 operations] language and ordinary rerenders reformat cached data without refetch or polling restart", async () => {
|
||||
const intervals = jest.spyOn(window, "setInterval"); const cleared = jest.spyOn(window, "clearInterval");
|
||||
serverOperations = [{ ...operation, taskType: "strategy.snapshot", status: "waiting_for_retry" }]; const { rerender } = renderPage();
|
||||
expect(await screen.findByText("Strategy snapshot")).toBeVisible(); expect(screen.getByText("Waiting to retry")).toBeVisible(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
const starts = intervals.mock.calls.length; const pollId = intervals.mock.results[intervals.mock.calls.findIndex(([, delay]) => delay === 15000)].value; await language("nb");
|
||||
expect(screen.getByText("Strategioversikt")).toBeVisible(); expect(screen.getByText("Venter på nytt forsøk")).toBeVisible();
|
||||
expect(screen.getByText(`Opprettet ${new Date(operation.createdAtUtc).toLocaleString("nb-NO")}`)).toBeVisible();
|
||||
rerender(<I18nProvider><OperationsPage /></I18nProvider>); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(intervals).toHaveBeenCalledTimes(starts); expect(cleared).not.toHaveBeenCalledWith(pollId);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] 15-second polling is single-flight and resumes after the held pair completes", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {}); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
|
||||
await act(async () => jest.advanceTimersByTime(30000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
|
||||
await act(async () => pending.resolve({ data: [] })); mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(6); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] mutation invalidates a held old poll before adopting the returned same-ID DTO", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {}); const oldOperations = deferred(); const oldNotices = deferred();
|
||||
mockedApi.get.mockImplementationOnce(() => oldOperations.promise).mockImplementationOnce(() => oldNotices.promise);
|
||||
await act(async () => jest.advanceTimersByTime(15000)); const signal = mockedApi.get.mock.calls[2][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal);
|
||||
const post = deferred(); const refresh = deferred(); mockedApi.post.mockImplementationOnce(() => post.promise); mockedApi.get.mockImplementation(() => refresh.promise);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(signal.aborted).toBe(true);
|
||||
const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
|
||||
await act(async () => post.resolve({ data: cancelled })); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Cancelled");
|
||||
await act(async () => { oldOperations.resolve({ data: [{ ...operation, progressStage: "Stale old poll" }] }); oldNotices.resolve({ data: [notification] }); });
|
||||
expect(screen.queryByText("Stale old poll")).not.toBeInTheDocument();
|
||||
await act(async () => refresh.resolve({ data: [] })); expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] committed cancel plus failed refresh announces truth and GET Retry cannot replay or unlock stale writes", async () => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
|
||||
mockedApi.post.mockResolvedValueOnce({ data: cancelled } as any); mockedApi.get.mockRejectedValue(new Error("read offline")); act(() => cancel.focus()); fireEvent.click(cancel);
|
||||
const failure = await screen.findByRole("alert"); expect(failure).toHaveTextContent("The action completed, but recent activity could not be refreshed. Refresh before taking another action.");
|
||||
const item = screen.getByRole("article", { name: operation.taskType }); expect(within(item).getByRole("button", { name: "Retry" })).toBeDisabled(); expect(item).toHaveTextContent("Cancelled"); expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
serverOperations = [cancelled]; mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
fireEvent.click(within(failure).getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(changed).toHaveBeenCalledTimes(1); expect(within(item).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test.each(["Mark read", "Dismiss"])("[P7-08 operations] committed204 %s updates notices locally even when refresh fails without completing a task", async label => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const action = await screen.findByRole("button", { name: label }); mockedApi.get.mockRejectedValue(new Error("offline")); fireEvent.click(action);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The action completed, but recent activity could not be refreshed.");
|
||||
expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); if (label === "Dismiss") expect(screen.queryByText(notification.title)).not.toBeInTheDocument(); else expect(screen.getByText(notification.title)).toBeVisible();
|
||||
expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Running"); expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
expect(mockedApi.post.mock.calls.filter(([url]) => String(url).startsWith("/operations"))).toEqual([]); expect(changed).toHaveBeenCalledTimes(1);
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] failed POST preserves eligibility and current-locale recovery without false commit or new GET", async () => {
|
||||
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); act(() => cancel.focus()); fireEvent.click(cancel); await language("nb"); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
await act(async () => pending.reject(new Error("offline"))); expect(await screen.findByRole("alert")).toHaveTextContent(translations.nb.operationsActionFailed); expect(screen.getByRole("button", { name: translations.nb.cancel })).toBeEnabled(); expect(screen.getByRole("button", { name: translations.nb.cancel })).toHaveFocus(); expect(changed).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
await language("en"); expect(screen.getByRole("alert")).toHaveTextContent(translations.en.operationsActionFailed); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] server action admission overrides displayed status and unknown metadata remains honest", async () => {
|
||||
serverOperations = [{ ...operation, taskType: "Unknown_Task_Type", status: "succeeded", progressStage: "arbitrary-stage", failureCategory: "unmapped_category", canCancel: true, canRetry: false }, { ...operation, id: "foreign-looking-local-fixture", taskType: "cv.process", status: "running", canCancel: false, canRetry: true }, { ...operation, id: "unmapped-prototype-code", taskType: "toString", status: "constructor", canCancel: false, canRetry: false }];
|
||||
renderPage(); expect(await screen.findByText("Unknown_Task_Type")).toBeVisible(); const first = screen.getByRole("article", { name: "Unknown_Task_Type" }); expect(first).toHaveAttribute("data-operation-id", operation.id); expect(within(first).getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(within(first).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); expect(first).toHaveTextContent("arbitrary-stage"); expect(first).toHaveTextContent("unmapped category");
|
||||
const second = screen.getByRole("article", { name: "CV processing" }); expect(second).toHaveAttribute("data-operation-id", "foreign-looking-local-fixture"); expect(within(second).queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(within(second).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
const unknown = screen.getByRole("article", { name: "toString" }); expect(unknown).toHaveAttribute("data-operation-id", "unmapped-prototype-code"); expect(unknown).toHaveTextContent("constructor"); expect(within(unknown).queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] unmount aborts a pending read and late completion cannot fetch or publish", async () => {
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise); const { unmount } = renderPage(); const signal = mockedApi.get.mock.calls[0][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal); unmount(); expect(signal.aborted).toBe(true); await act(async () => pending.resolve({ data: [operation] })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] unmount during a write prevents its late follow-up GET, event and focus effects", async () => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed); const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise);
|
||||
try { const { unmount } = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); unmount(); await act(async () => pending.resolve({ data: { ...operation, status: "cancelled" } })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(changed).not.toHaveBeenCalled(); } finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] removing the initiating control restores meaningful focus while deliberate focus elsewhere stays put", async () => {
|
||||
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); renderPage(); const mark = await screen.findByRole("button", { name: "Mark read" }); act(() => mark.focus()); fireEvent.click(mark); serverNotifications = [{ ...notification, readAtUtc: "2026-08-02T12:06:00Z" }]; await act(async () => pending.resolve({ data: undefined })); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
const next = deferred(); mockedApi.delete.mockImplementationOnce(() => next.promise); const dismiss = screen.getByRole("button", { name: "Dismiss" }); act(() => dismiss.focus()); fireEvent.click(dismiss); const external = document.createElement("button"); external.textContent = "Deliberate external target"; document.body.appendChild(external); act(() => external.focus()); serverNotifications = []; await act(async () => next.resolve({ data: undefined })); expect(external).toHaveFocus(); external.remove();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] accepted background polls preserve surviving and deliberate focus and recover removed focused controls", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {});
|
||||
const cancel = screen.getByRole("button", { name: "Cancel" }); act(() => cancel.focus());
|
||||
serverOperations = [{ ...operation, progressStage: "Fresh progress with the same action" }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(cancel).toHaveFocus(); expect(cancel.isConnected).toBe(true);
|
||||
const external = document.createElement("button"); external.textContent = "Deliberate target outside recent activity"; document.body.appendChild(external);
|
||||
try {
|
||||
act(() => external.focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus(); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
serverOperations = [{ ...operation }]; await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus();
|
||||
act(() => screen.getByRole("button", { name: "Cancel" }).focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
act(() => screen.getByRole("button", { name: "Mark read" }).focus()); serverNotifications = [];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(12); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
} finally { external.remove(); }
|
||||
});
|
||||
|
||||
@@ -1,147 +1,172 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { notificationDateLabel, UserNotification } from "../notifications";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
import { notificationDateLabel, type UserNotification } from "../notifications";
|
||||
import type { UserOperation } from "../types";
|
||||
|
||||
type Operation = {
|
||||
id: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
subjectType?: string | null;
|
||||
createdAtUtc: string;
|
||||
completedAtUtc?: string | null;
|
||||
cancellationRequestedAtUtc?: string | null;
|
||||
progressStage?: string | null;
|
||||
progressPercent?: number | null;
|
||||
failureCategory?: string | null;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
const statusKeys: Record<string, TranslationKey> = {
|
||||
queued: "operationsStatusQueued", running: "operationsStatusRunning",
|
||||
waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback",
|
||||
succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled",
|
||||
};
|
||||
|
||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||
const taskKeys: Record<string, TranslationKey> = { "cv.process": "operationsTaskCv", "strategy.snapshot": "operationsTaskStrategy" };
|
||||
const buttonSx = { minHeight: { xs: 44, sm: 36 }, whiteSpace: "normal", overflowWrap: "anywhere", "@media (pointer: coarse)": { minHeight: 44 } } as const;
|
||||
type Failure = { cause: unknown; kind: "load" | "action" | "committed" };
|
||||
type ActionUpdate = { operation?: UserOperation; readId?: string; dismissId?: string };
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [operations, setOperations] = useState<UserOperation[]>([]);
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<Failure | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const sequence = useRef(0);
|
||||
const activeLoad = useRef<AbortController | null>(null);
|
||||
const pendingAction = useRef(false);
|
||||
const refreshRequired = useRef(false);
|
||||
const activityRef = useRef<HTMLDivElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionFocus = useRef<HTMLElement | null>(null);
|
||||
const readFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
const load = useCallback(async (afterWrite = false) => {
|
||||
if (!mounted.current || activeLoad.current || pendingAction.current && !afterWrite) return;
|
||||
const request = ++sequence.current;
|
||||
const controller = new AbortController();
|
||||
activeLoad.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [operationResponse, notificationResponse] = await Promise.all([
|
||||
api.get<Operation[]>("/operations?limit=50"),
|
||||
api.get<UserNotification[]>("/notifications?limit=50"),
|
||||
api.get<UserOperation[]>("/operations?limit=50", { signal: controller.signal }),
|
||||
api.get<UserNotification[]>("/notifications?limit=50", { signal: controller.signal }),
|
||||
]);
|
||||
if (!mounted.current || controller.signal.aborted || request !== sequence.current) return;
|
||||
const active = document.activeElement;
|
||||
readFocus.current = active instanceof HTMLElement && activityRef.current?.contains(active) ? active : null;
|
||||
setOperations(operationResponse.data ?? []);
|
||||
setNotifications(notificationResponse.data ?? []);
|
||||
setHasLoaded(true);
|
||||
refreshRequired.current = false;
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
|
||||
} catch (cause) {
|
||||
if (mounted.current && !controller.signal.aborted && request === sequence.current) {
|
||||
setError({ cause, kind: refreshRequired.current ? "committed" : "load" });
|
||||
controller.abort();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted.current && request === sequence.current) { activeLoad.current = null; setLoading(false); }
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void load();
|
||||
const interval = window.setInterval(() => void load(), 15000);
|
||||
return () => window.clearInterval(interval);
|
||||
return () => { mounted.current = false; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; window.clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
|
||||
if (busyKey) return;
|
||||
useEffect(() => {
|
||||
if (loading || busyKey || !actionFocus.current) return;
|
||||
const target = actionFocus.current;
|
||||
actionFocus.current = null;
|
||||
if (document.activeElement !== activityRef.current) return;
|
||||
if (target.isConnected && !target.matches(":disabled")) target.focus();
|
||||
else headingRef.current?.focus();
|
||||
}, [busyKey, error, loading, notifications, operations]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = readFocus.current;
|
||||
readFocus.current = null;
|
||||
if (target && !target.isConnected && document.activeElement === document.body) headingRef.current?.focus();
|
||||
}, [loading, notifications, operations]);
|
||||
|
||||
const rememberFocus = () => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && active !== activityRef.current && activityRef.current?.contains(active)) {
|
||||
actionFocus.current = active;
|
||||
activityRef.current.focus();
|
||||
}
|
||||
};
|
||||
const refresh = () => { rememberFocus(); void load(); };
|
||||
const runAction = async (key: string, action: () => Promise<ActionUpdate>) => {
|
||||
if (!mounted.current || pendingAction.current || refreshRequired.current) return;
|
||||
pendingAction.current = true;
|
||||
sequence.current += 1;
|
||||
activeLoad.current?.abort();
|
||||
activeLoad.current = null;
|
||||
setLoading(false);
|
||||
rememberFocus();
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
|
||||
const update = await action();
|
||||
if (!mounted.current) return;
|
||||
if (update.operation) setOperations(items => items.map(item => item.id === update.operation!.id ? update.operation! : item));
|
||||
if (update.readId) setNotifications(items => items.map(item => item.id === update.readId ? { ...item, readAtUtc: new Date().toISOString() } : item));
|
||||
if (update.dismissId) setNotifications(items => items.filter(item => item.id !== update.dismissId));
|
||||
// A failed follow-up read must never invite replay of a committed write.
|
||||
refreshRequired.current = true;
|
||||
window.dispatchEvent(new Event("notifications-changed"));
|
||||
await load(true);
|
||||
} catch (cause) {
|
||||
if (mounted.current) setError({ cause, kind: "action" });
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
if (mounted.current) { pendingAction.current = false; setBusyKey(null); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
|
||||
</Box>
|
||||
const locked = busyKey !== null || error?.kind === "committed";
|
||||
const taskLabel = (task: string) => Object.hasOwn(taskKeys, task) ? t(taskKeys[task]) : task;
|
||||
const dateLabel = (value: string) => notificationDateLabel(value, language === "nb" ? "nb-NO" : "en-GB");
|
||||
const errorTitle = error ? t(error.kind === "committed" ? "operationsCommittedRefreshFailed" : error.kind === "action" ? "operationsActionFailed" : "operationsLoadFailed") : "";
|
||||
const errorDetail = error ? getApiErrorMessage(error.cause, errorTitle) : "";
|
||||
|
||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
|
||||
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{notifications.map((notification) => (
|
||||
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
|
||||
<Typography color="text.secondary">{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? (
|
||||
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
||||
{t("notificationsMarkRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||
{t("notificationsDismiss")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
return <Stack ref={activityRef} role="region" aria-label={t("operationsActivity")} aria-busy={loading || busyKey !== null} tabIndex={-1} spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("operationsActivity")} titleRef={headingRef} description={t("operationsSubtitle")} actions={<Button variant="outlined" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("refresh")}</Button>} />
|
||||
<Typography variant="body2" color="text.secondary">{t("operationsRecentHelp")}</Typography>
|
||||
{error ? <Alert severity="error" sx={{ overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("retry")}</Button>}>
|
||||
<Typography variant="body2">{errorTitle}</Typography>
|
||||
{errorDetail !== errorTitle ? <Typography variant="body2">{errorDetail}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
<PageSection surface title={t("notifications")}>
|
||||
{hasLoaded && notifications.length === 0 ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{notifications.map(notification => <Paper key={notification.id} component="article" aria-label={notification.title} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Typography component="h3" variant="sectionTitle">{notification.title}</Typography>
|
||||
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? <Button size="small" disabled={locked} sx={buttonSx} onClick={() => void runAction("read-" + notification.id, async () => { await api.post("/notifications/" + notification.id + "/read"); return { readId: notification.id }; })}>{t("notificationsMarkRead")}</Button> : null}
|
||||
<Button size="small" color="inherit" disabled={locked} sx={buttonSx} onClick={() => void runAction("dismiss-" + notification.id, async () => { await api.delete("/notifications/" + notification.id); return { dismissId: notification.id }; })}>{t("notificationsDismiss")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{operations.map((operation) => (
|
||||
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
|
||||
<Chip size="small" label={statusLabel(operation.status)} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
|
||||
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? (
|
||||
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{operation.canRetry ? (
|
||||
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
<PageSection surface title={t("operations")}>
|
||||
{hasLoaded && operations.length === 0 ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{operations.map(operation => <Paper key={operation.id} component="article" aria-label={taskLabel(operation.taskType)} data-operation-id={operation.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere", minWidth: 0 }}>{taskLabel(operation.taskType)}</Typography>
|
||||
<Chip size="small" label={Object.hasOwn(statusKeys, operation.status) ? t(statusKeys[operation.status]) : operation.status.replaceAll("_", " ")} sx={{ maxWidth: "100%", height: "auto", "& .MuiChip-label": { whiteSpace: "normal", py: 0.5, overflowWrap: "anywhere" } }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsCreated", { date: dateLabel(operation.createdAtUtc) })}</Typography>
|
||||
{operation.progressStage ? <Typography variant="body2" sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: taskLabel(operation.taskType) })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1, overflowWrap: "anywhere" }}>{t("operationsFailed", { category: operation.failureCategory.replaceAll("_", " ") })}</Alert> : null}
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? <Button size="small" color="error" disabled={locked} sx={buttonSx} onClick={() => void runAction("cancel-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/cancel")).data }))}>{t("cancel")}</Button> : null}
|
||||
{operation.canRetry ? <Button size="small" variant="outlined" disabled={locked} sx={buttonSx} onClick={() => void runAction("retry-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/retry")).data }))}>{t("retry")}</Button> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user