feat(ui): migrate reminders and protect follow-up recovery
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
import { Box, Button, Chip, Divider, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import { statusLabel } from "../pipeline";
|
||||
|
||||
type FollowUpFailure = {
|
||||
job: Pick<JobApplication, "id" | "jobTitle">;
|
||||
days: number | null;
|
||||
date: string | null;
|
||||
error: ViewResourceError;
|
||||
};
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
@@ -27,13 +36,16 @@ function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (job: JobApplication) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
const { t } = useI18n();
|
||||
function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; disabled: boolean; onOpen: (job: JobApplication) => void; onSetFollowUp: (job: JobApplication, days: number | null) => void }) {
|
||||
const { language, t } = useI18n();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Box component="section" aria-label={title} sx={{ display: "flex", flexDirection: "column", gap: 1.25, minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography component="h3" variant="sectionTitle">{title}</Typography>
|
||||
<Chip size="small" label={items.length} />
|
||||
</Stack>
|
||||
{items.map((j) => {
|
||||
const action = getWorkflowAction(j, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
@@ -43,23 +55,22 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
<Paper key={j.id} component="article" aria-label={j.jobTitle} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1.5, alignItems: "center" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography component="h4" variant="body1" sx={{ fontWeight: 700, lineHeight: 1.4, overflowWrap: "anywhere" }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={statusLabel(t, j.status)} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", minWidth: 0, justifyContent: { xs: "flex-start", md: "flex-end" }, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
<Button size="small" variant="outlined" disabled={disabled} aria-label={`${action?.label ?? t("remindersOpen")}: ${j.jobTitle}`} onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
{[3, 7].map(days => <Button key={days} size="small" variant="outlined" disabled={disabled} aria-label={t("remindersSnoozeForJob", { days, job: j.jobTitle })} onClick={() => onSetFollowUp(j, days)}>{t("remindersSnoozeDays", { days })}</Button>)}
|
||||
<Button size="small" disabled={disabled} aria-label={t("remindersClearForJob", { job: j.jobTitle })} onClick={() => onSetFollowUp(j, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
@@ -72,6 +83,10 @@ export default function RemindersView() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const pending = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failure, setFailure] = useState<FollowUpFailure | null>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remindersResource = useViewResource(
|
||||
async () => {
|
||||
@@ -80,59 +95,81 @@ export default function RemindersView() {
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("remindersLoadFailed"),
|
||||
deps: [t],
|
||||
errorMessage: "",
|
||||
},
|
||||
);
|
||||
|
||||
const items = remindersResource.data;
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
const loading = busy || remindersResource.loading || remindersResource.refreshing;
|
||||
const disabled = loading || !!remindersResource.error;
|
||||
|
||||
const openJob = (job: JobApplication) => {
|
||||
if (pending.current || disabled) return;
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
const setFollowUp = async (job: Pick<JobApplication, "id" | "jobTitle">, daysFromNow: number | null,
|
||||
date = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)) => {
|
||||
if (pending.current || disabled) return;
|
||||
pending.current = true;
|
||||
resultsRef.current?.focus();
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
await api.patch(`/jobapplications/${job.id}/followup`, { followUpAt: date });
|
||||
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
|
||||
await remindersResource.reload();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
setFailure({ job: { id: job.id, jobTitle: job.jobTitle }, days: daysFromNow, date,
|
||||
error: { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status } });
|
||||
toast(t("remindersFollowUpFailed"), "error");
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("remindersSubtitle")}
|
||||
</Typography>
|
||||
const retryRead = async () => {
|
||||
if (pending.current || loading) return;
|
||||
pending.current = true;
|
||||
resultsRef.current?.focus();
|
||||
setBusy(true);
|
||||
try { await remindersResource.reload(); }
|
||||
finally { pending.current = false; setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("remindersTitle")} description={t("remindersSubtitle")} />
|
||||
|
||||
<Box ref={resultsRef} role="region" aria-label={t("remindersTitle")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice
|
||||
loading={remindersResource.loading}
|
||||
error={remindersResource.error}
|
||||
title={t("remindersLoadFailed")}
|
||||
description={t("remindersLoadFailedBody")}
|
||||
onRetry={remindersResource.reload}
|
||||
compact loading={loading}
|
||||
error={failure?.error ?? (remindersResource.error ? { ...remindersResource.error, message: remindersResource.error.message || t("remindersLoadFailed") } : null)}
|
||||
title={failure ? t("remindersMutationFailedForJob", { job: failure.job.jobTitle }) : t("remindersLoadFailed")}
|
||||
description={failure ? undefined : t("remindersLoadFailedBody")}
|
||||
onRetry={failure ? () => setFollowUp(failure.job, failure.days, failure.date) : retryRead}
|
||||
/>
|
||||
|
||||
{!remindersResource.loading && !remindersResource.error ? (
|
||||
{!remindersResource.loading && remindersResource.error?.kind !== "unauthorized" ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
{!loading && !remindersResource.error && items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("remindersTip")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1617,6 +1617,10 @@ export const translations = {
|
||||
remindersTip: "Tip: focus on tailored CV and interview prep first for the highest-value roles.",
|
||||
remindersOpen: "Open",
|
||||
remindersClear: "Clear",
|
||||
remindersSnoozeDays: "+{days} days",
|
||||
remindersSnoozeForJob: "+{days} days: set follow-up for {job}",
|
||||
remindersClearForJob: "Clear follow-up: {job}",
|
||||
remindersMutationFailedForJob: "Could not update follow-up for {job}.",
|
||||
remindersFollowUpLabel: "Follow up",
|
||||
remindersFollowUpDate: "Follow-up: {date}",
|
||||
remindersFollowUpCleared: "Follow-up cleared.",
|
||||
@@ -4262,6 +4266,10 @@ export const translations = {
|
||||
remindersTip: "Tips: fokuser på skreddersydd CV og intervjuforberedelse først for de mest verdifulle rollene.",
|
||||
remindersOpen: "Åpne",
|
||||
remindersClear: "Fjern",
|
||||
remindersSnoozeDays: "+{days} dager",
|
||||
remindersSnoozeForJob: "+{days} dager: sett oppfølging for {job}",
|
||||
remindersClearForJob: "Fjern oppfølging: {job}",
|
||||
remindersMutationFailedForJob: "Kunne ikke oppdatere oppfølging for {job}.",
|
||||
remindersFollowUpLabel: "Følg opp",
|
||||
remindersFollowUpDate: "Oppfølging: {date}",
|
||||
remindersFollowUpCleared: "Oppfølging fjernet.",
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import RemindersView from "./components/RemindersView";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations, type UiLanguage } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import type { JobApplication, WorkflowSignal } from "./types";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), patch: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const readRequest = { params: { upcomingDays: 14 } };
|
||||
const now = Date.parse("2026-11-15T23:30:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = "en";
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.patch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function language(): UiLanguage {
|
||||
return document.documentElement.lang === "nb-NO" ? "nb" : "en";
|
||||
}
|
||||
|
||||
function copy(key: string, params: Record<string, string | number> = {}, locale: UiLanguage = language()) {
|
||||
const template = (translations[locale] as Record<string, string>)[key] ?? key;
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
function signal(actionKey: WorkflowSignal["actionKey"], workspaceTab: WorkflowSignal["workspaceTab"], reason: string): WorkflowSignal {
|
||||
return {
|
||||
actionKey, workspaceTab, reason, needsAttention: true,
|
||||
hasPackageGap: actionKey === "package-work", needsInterviewPrep: actionKey === "interview-prep",
|
||||
needsFollowUpAction: actionKey === "follow-up", hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true, hasInterviewPrepNotes: true,
|
||||
};
|
||||
}
|
||||
|
||||
function job(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
|
||||
return {
|
||||
id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting",
|
||||
dateApplied: "2026-11-01", savedAt: "2026-10-20", responseReceived: false, daysSince: 14,
|
||||
needsFollowUp: true, followUpAt: "2026-11-17", followUpReason: "Legacy source reason",
|
||||
workflowSignal: signal("follow-up", "follow-up", "Recorded workflow reason"), ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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 RouteContext() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="Current route">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(<ToastProvider><I18nProvider><MemoryRouter initialEntries={["/reminders"]}>
|
||||
<RouteContext /><button>Outside reminder controls</button><RemindersView />
|
||||
</MemoryRouter></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
function changeLanguage(locale: UiLanguage) {
|
||||
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); });
|
||||
}
|
||||
|
||||
// Mutation contracts also run against the legacy row presentation, so RED reaches
|
||||
// duplicate-write/recovery behavior instead of stopping at the new article name.
|
||||
// The first case separately requires every migrated article and action name.
|
||||
function card(title: string): HTMLElement {
|
||||
const article = screen.queryByRole("article", { name: title });
|
||||
if (article) return article;
|
||||
const label = screen.getByText(new RegExp(title));
|
||||
return label.closest<HTMLElement>(".MuiPaper-root")!;
|
||||
}
|
||||
|
||||
function action(title: string, days: number | null): HTMLElement {
|
||||
const name = days === null ? copy("remindersClearForJob", { job: title }) : copy("remindersSnoozeForJob", { days, job: title });
|
||||
return within(card(title)).queryByRole("button", { name })
|
||||
?? within(card(title)).getByRole("button", { name: days === null ? copy("remindersClear") : `+${days}d` });
|
||||
}
|
||||
|
||||
function region() {
|
||||
return screen.getByRole("region", { name: copy("remindersTitle") });
|
||||
}
|
||||
|
||||
function feedbackScope(): HTMLElement {
|
||||
return screen.queryByRole("region", { name: copy("remindersTitle") })
|
||||
?? screen.getByText(copy("remindersTitle")).closest<HTMLElement>(".MuiPaper-root")!;
|
||||
}
|
||||
|
||||
function assertNoOtherWrites() {
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
test("preserves all four server-signal groups, raw reasons, contextual destinations and legacy fallback", async () => {
|
||||
const items = [
|
||||
job(11, { workflowSignal: signal("package-work", "tailored-cv", "Source CV reason"), tailoredCvText: "Existing text is not a client grouping rule" }),
|
||||
job(22, { status: "Interview", workflowSignal: signal("interview-prep", "interview-prep", "Source interview reason") }),
|
||||
job(33, { workflowSignal: { ...signal("follow-up", "follow-up", "Signal wins over legacy reason"), followMode: "waiting-update" }, followUpReason: "Tailored CV missing" }),
|
||||
job(44, { workflowSignal: signal("review-readiness", "readiness", "Source readiness reason") }),
|
||||
job(55, { status: "Custom hiring stage", workflowSignal: undefined, followUpReason: "Raw legacy reason — æøå" }),
|
||||
];
|
||||
mockedApi.get.mockResolvedValue({ data: items } as any);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument();
|
||||
await screen.findByRole("article", { name: items[0].jobTitle });
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([
|
||||
copy("remindersMissingTailoredCv"), copy("remindersMissingInterviewPrep"), copy("remindersFollowUpDue"), copy("remindersOther"),
|
||||
]);
|
||||
const rowHeadings = screen.getAllByRole("heading", { level: 4 });
|
||||
expect(rowHeadings).toHaveLength(items.length);
|
||||
items.forEach((item, index) => expect(rowHeadings[index]).toHaveTextContent(item.jobTitle));
|
||||
expect(screen.getAllByRole("article")).toHaveLength(items.length);
|
||||
const paths = ["/jobs/11?section=cv", "/jobs/22?section=interview", "/jobs/33?section=communication&followMode=waiting-update", "/jobs/44?section=checklist", "/jobs/55"];
|
||||
const labels = ["jobTablePackageWork", "jobTableInterviewStage", "jobTableFollowUp", "jobTableReadiness", "remindersOpen"];
|
||||
items.forEach((item, index) => {
|
||||
const row = screen.getByRole("article", { name: item.jobTitle });
|
||||
expect(within(row).getByText(item.workflowSignal?.reason ?? item.followUpReason!)).toBeInTheDocument();
|
||||
for (const days of [3, 7]) expect(within(row).getByRole("button", { name: copy("remindersSnoozeForJob", { days, job: item.jobTitle }) })).toBeEnabled();
|
||||
expect(within(row).getByRole("button", { name: copy("remindersClearForJob", { job: item.jobTitle }) })).toBeEnabled();
|
||||
fireEvent.click(within(row).getByRole("button", { name: new RegExp(copy(labels[index]), "i") }));
|
||||
expect(screen.getByLabelText("Current route").textContent).toBe(paths[index]);
|
||||
});
|
||||
expect(screen.queryByText("Tailored CV missing")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ days: 3, expectedDate: "2026-11-18" },
|
||||
{ days: 7, expectedDate: "2026-11-22" },
|
||||
{ days: null, expectedDate: null },
|
||||
])("a held $days-day action submits one snapshot and locks every row action through refresh", async ({ days, expectedDate }) => {
|
||||
jest.spyOn(Date, "now").mockReturnValue(now);
|
||||
const target = job(71);
|
||||
const other = job(72);
|
||||
const write = deferred<any>();
|
||||
const refresh = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target, other] } as any).mockImplementationOnce(() => refresh.promise);
|
||||
mockedApi.patch.mockImplementation(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const intended = action(target.jobTitle, days);
|
||||
const competing = action(other.jobTitle, days === 3 ? 7 : 3);
|
||||
const clear = action(target.jobTitle, null);
|
||||
intended.focus();
|
||||
act(() => { fireEvent.click(intended); fireEvent.click(competing); fireEvent.click(clear); });
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/71/followup", { followUpAt: expectedDate });
|
||||
for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled());
|
||||
expect(region()).toHaveFocus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled());
|
||||
await act(async () => { refresh.resolve({ data: [target, other] }); });
|
||||
await waitFor(() => expect(action(target.jobTitle, days)).toBeEnabled());
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a failed PATCH retains the row and durable localized error; Retry reuses its captured date while a fresh action uses the new clock", async () => {
|
||||
const clock = jest.spyOn(Date, "now").mockReturnValue(now);
|
||||
const target = job(81);
|
||||
mockedApi.get.mockResolvedValue({ data: [target] } as any);
|
||||
mockedApi.patch.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValue({ status: 204 } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
fireEvent.click(action(target.jobTitle, 3));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(1));
|
||||
expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle }));
|
||||
expect(card(target.jobTitle)).toBeVisible();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
changeLanguage("nb");
|
||||
expect(within(feedbackScope()).getByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle }));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
clock.mockReturnValue(Date.parse("2026-11-25T23:30:00Z"));
|
||||
fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") }));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(1, "/jobapplications/81/followup", { followUpAt: "2026-11-18" });
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(2, "/jobapplications/81/followup", { followUpAt: "2026-11-18" });
|
||||
await waitFor(() => expect(within(feedbackScope()).queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(action(target.jobTitle, 3));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(3));
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(3, "/jobapplications/81/followup", { followUpAt: "2026-11-28" });
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("after PATCH 204 and failed refresh, Retry reads only reminders and never repeats the event-producing PATCH", async () => {
|
||||
const target = job(91);
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any)
|
||||
.mockRejectedValueOnce({ response: { status: 503 } })
|
||||
.mockResolvedValueOnce({ data: [target] } as any);
|
||||
mockedApi.patch.mockResolvedValue({ status: 204 } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
fireEvent.click(action(target.jobTitle, null));
|
||||
expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
expect(within(feedbackScope()).queryByText(copy("remindersMutationFailedForJob", { job: target.jobTitle }))).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/91/followup", { followUpAt: null });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") }));
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(3, "/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("live EN/Bokmål presentation uses explicit date locale and canonical statuses while preserving raw reasons without GET or writes", async () => {
|
||||
const originalDate = Date.prototype.toLocaleDateString;
|
||||
jest.spyOn(Date.prototype, "toLocaleDateString").mockImplementation(function (
|
||||
this: Date,
|
||||
locales?: Parameters<typeof Date.prototype.toLocaleDateString>[0],
|
||||
options?: Parameters<typeof Date.prototype.toLocaleDateString>[1],
|
||||
) { return originalDate.call(this, locales ?? "de-DE", options); });
|
||||
const target = job(101, { followUpReason: "Do not replace the workflow reason", workflowSignal: signal("follow-up", "follow-up", "Raw source reason — æøå") });
|
||||
const custom = job(102, { status: "Faglig vurdering", workflowSignal: undefined, followUpReason: "Custom reason stays literal" });
|
||||
mockedApi.get.mockResolvedValue({ data: [target, custom] } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "en") }))).toBeInTheDocument();
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "nb-NO") }))).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(translations.nb.statusWaiting)).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(target.workflowSignal!.reason)).toBeInTheDocument();
|
||||
expect(screen.queryByText(target.followUpReason!)).not.toBeInTheDocument();
|
||||
expect(within(card(custom.jobTitle)).getByText(custom.status)).toBeInTheDocument();
|
||||
expect(within(card(custom.jobTitle)).getByText(custom.followUpReason!)).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a generic read failure translates live without GET and explicit read Retry preserves its original request", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: [] } as any);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("when an action removes its row, focus stays in the named results region instead of a removed button", async () => {
|
||||
const target = job(111, { status: "Custom stage", workflowSignal: undefined });
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any);
|
||||
mockedApi.patch.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const clear = action(target.jobTitle, null);
|
||||
clear.focus();
|
||||
fireEvent.click(clear);
|
||||
expect(region()).toHaveFocus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument();
|
||||
expect(region()).toHaveFocus();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a removed reminder row does not steal focus deliberately moved outside while its PATCH is held", async () => {
|
||||
const target = job(121, { status: "Custom stage", workflowSignal: undefined });
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any);
|
||||
mockedApi.patch.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const clear = action(target.jobTitle, null);
|
||||
clear.focus();
|
||||
fireEvent.click(clear);
|
||||
const outside = screen.getByRole("button", { name: "Outside reminder controls" });
|
||||
outside.focus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(outside).toHaveFocus();
|
||||
expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
Reference in New Issue
Block a user