feat(ui): migrate company relationships and save recovery

This commit is contained in:
cesnimda
2026-10-13 08:59:02 +02:00
parent 34a4d6dc6b
commit a8aba85299
14 changed files with 5717 additions and 62 deletions
@@ -1,7 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import CompaniesTable from "./components/CompaniesTable";
import { I18nProvider } from "./i18n/I18nProvider";
@@ -22,7 +22,7 @@ beforeEach(() => {
test("opens one company relationship view with applications and recent contact", async () => {
const company = { id: 3, name: "Acme", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("/relationship") ? {
company,
company: { ...company, name: "Acme refreshed" },
applications: [{ id: 9, jobTitle: "Backend Developer", status: "Interview", messageCount: 1 }],
recentContacts: [{ id: 12, jobApplicationId: 9, jobTitle: "Backend Developer", date: "2026-08-10T10:00:00Z", channel: "email", subject: "Interview", from: "Rita" }],
} : [company] } as any));
@@ -34,16 +34,18 @@ test("opens one company relationship view with applications and recent contact",
expect(screen.getAllByText("Backend Developer").length).toBeGreaterThan(0);
expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship");
expect(await screen.findByRole("dialog", { name: "Acme refreshed" })).toBeInTheDocument();
});
test.each(["en", "nb"])("company contact dates follow the %s UI locale", async (language) => {
window.localStorage.setItem("uiLanguage", language);
const nextContactAt = "2026-11-23T12:00:00Z";
mockedApi.get.mockResolvedValue({ data: [{ id: 3, name: "Acme", nextContactAt }] });
mockedApi.get.mockResolvedValue({ data: [{ id: 3, name: "Acme", nextContactAt, pipelineStage: "Interview" }] });
render(<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
const expected = new Date(nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en");
await screen.findByRole("button", { name: `${language === "nb" ? "Rediger" : "Edit"}: Acme` });
expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent(expected);
expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent("Interview");
});
test.each([
@@ -72,3 +74,158 @@ test.each([
expect(await screen.findByRole("button", { name: `${language === "en" ? "Edit" : "Rediger"}: Acme updated` })).toBeVisible();
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/companies/3", expect.objectContaining({ name: "Acme updated" }));
});
const companyA = { id: 3, name: "Acme", location: "Oslo", source: "User", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
const companyB = { id: 4, name: "Northstar", location: "Bergen", source: "User" };
function relationship(company: { id: number; name: string } = companyA) {
return { data: { company, applications: [{ id: company.id + 10, jobTitle: `${company.name} role`, status: "Interview", messageCount: 1 }], recentContacts: [] } };
}
function RouteContext() {
const location = useLocation();
const navigate = useNavigate();
return <><output aria-label="Route context">{JSON.stringify({ pathname: location.pathname, search: location.search, hash: location.hash, state: location.state })}</output><button onClick={() => navigate(-1)}>Previous route</button></>;
}
function renderPage(initialEntries: any[] = ["/companies"]) {
return render(<I18nProvider><ToastProvider><MemoryRouter initialEntries={initialEntries}><RouteContext /><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
}
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 };
}
test("a held company save locks all nine fields and cancellation, submits one exact snapshot, and retains a failed draft", async () => {
const write = deferred<any>();
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
mockedApi.put.mockImplementationOnce(() => write.promise);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" }));
const dialog = await screen.findByRole("dialog", { name: "Edit Company" });
const draft = {
Name: "Acme updated", Location: "Bergen", Source: "User", "Pipeline stage": " Interview ",
"Recruiter name": " Updated recruiter ", "Recruiter email": " updated@example.test ",
"Recruiter LinkedIn": " https://example.test/recruiter ", "Last contacted": "2026-11-21", "Next contact": "2026-11-23",
};
Object.entries(draft).forEach(([label, value]) => fireEvent.change(within(dialog).getByLabelText(label), { target: { value } }));
const save = within(dialog).getByRole("button", { name: "Save" });
fireEvent.click(save);
expect(mockedApi.put).toHaveBeenCalledWith("/companies/3", {
name: "Acme updated", location: "Bergen", source: "User", recruiterName: "Updated recruiter",
recruiterEmail: "updated@example.test", recruiterLinkedIn: "https://example.test/recruiter",
pipelineStage: "Interview", lastContactedAt: "2026-11-21", nextContactAt: "2026-11-23",
});
expect(save).toBeDisabled();
Object.keys(draft).forEach((label) => expect(within(dialog).getByLabelText(label)).toBeDisabled());
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeDisabled();
fireEvent.click(save);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(dialog).toBeVisible();
await act(async () => { write.reject(new Error("synthetic save failure")); });
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company.");
Object.entries(draft).forEach(([label, value]) => expect(within(dialog).getByLabelText(label)).toHaveValue(value));
expect(save).toBeEnabled();
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeEnabled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("a locale change translates an existing company-save failure without refetching or replacing its draft", async () => {
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
mockedApi.put.mockRejectedValueOnce(new Error("synthetic save failure"));
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "Name" }), { target: { value: "Acme unsaved" } });
fireEvent.change(within(dialog).getByRole("textbox", { name: "Recruiter name" }), { target: { value: "Unsaved recruiter" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company.");
const reads = mockedApi.get.mock.calls.length;
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
expect(within(dialog).getByRole("alert")).toHaveTextContent("Kunne ikke oppdatere selskap.");
expect(within(dialog).getByRole("textbox", { name: "Navn" })).toHaveValue("Acme unsaved");
expect(within(dialog).getByRole("textbox", { name: "Navn på rekrutterer" })).toHaveValue("Unsaved recruiter");
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("retrying a failed relationship read loads the same company without reloading the company list", async () => {
const retry = deferred<any>();
let reads = 0;
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship"
? ++reads === 1 ? Promise.reject(new Error("synthetic relationship failure")) : retry.promise
: Promise.resolve({ data: [companyA] }) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
const dialog = await screen.findByRole("dialog");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Could not load the relationship history.");
fireEvent.click(within(dialog).getByRole("button", { name: "Retry" }));
expect(reads).toBe(2);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/companies")).toHaveLength(1);
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
await act(async () => { retry.resolve(relationship()); });
expect(await within(dialog).findByText("Acme role")).toBeInTheDocument();
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
expect(reads).toBe(2);
});
test.each(["success", "failure"])("a late %s for relationship A cannot replace the opened relationship B", async (outcome) => {
const previous = deferred<any>();
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise
: Promise.resolve(url === "/companies/4/relationship" ? relationship(companyB) : { data: [companyA, companyB] }) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
fireEvent.click(await screen.findByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" }));
const dialog = await screen.findByRole("dialog");
expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument();
await act(async () => {
if (outcome === "success") previous.resolve(relationship());
else previous.reject(new Error("late A relationship failure"));
});
expect(within(dialog).getByText("Northstar role")).toBeInTheDocument();
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
expect(mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/relationship"))).toHaveLength(2);
});
test("a relationship response received after closing stays closed and does not populate another held relationship", async () => {
const previous = deferred<any>();
const next = deferred<any>();
mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise
: url === "/companies/4/relationship" ? next.promise : Promise.resolve({ data: [companyA, companyB] }) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
fireEvent.click(await screen.findByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await act(async () => { previous.resolve(relationship()); });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument();
expect(within(dialog).queryByText("Northstar role")).not.toBeInTheDocument();
await act(async () => { next.resolve(relationship(companyB)); });
expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument();
});
test("the company edit deep link removes only edit while preserving query, hash, state and replace navigation", async () => {
mockedApi.get.mockResolvedValue({ data: [companyA] } as any);
renderPage([
{ pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } },
{ pathname: "/companies", search: "?edit=3&return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } },
]);
const route = screen.getByLabelText("Route context");
expect(await screen.findByRole("dialog", { name: "Edit Company" })).toBeInTheDocument();
await waitFor(() => expect(JSON.parse(route.textContent!).search).toBe("?return=jobs%2F9"));
expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/companies", search: "?return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Previous route" }));
expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
+110 -50
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
@@ -30,7 +30,8 @@ import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
import { statusLabel } from "../pipeline";
export default function CompaniesTable() {
const isMobile = useMediaQuery("(max-width:767.95px)");
@@ -39,12 +40,22 @@ export default function CompaniesTable() {
const location = useLocation();
const navigate = useNavigate();
const [editOpen, setEditOpen] = useState(false);
const [editError, setEditError] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [editing, setEditing] = useState<Company | null>(null);
const [saving, setSaving] = useState(false);
const savePending = useRef(false);
const editContentRef = useRef<HTMLDivElement>(null);
const editCompanyIdRef = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [focusReturn, setFocusReturn] = useState<{ action: "edit" | "relationship"; id?: number | null } | null>(null);
const [relationshipOpen, setRelationshipOpen] = useState(false);
const [relationshipCompany, setRelationshipCompany] = useState<Company | null>(null);
const [relationshipLoading, setRelationshipLoading] = useState(false);
const [relationshipError, setRelationshipError] = useState("");
const [relationshipError, setRelationshipError] = useState<ViewResourceError | null>(null);
const [relationship, setRelationship] = useState<CompanyRelationship | null>(null);
const relationshipSequence = useRef(0);
const relationshipPending = useRef(false);
const relationshipContentRef = useRef<HTMLDivElement>(null);
const [recruiterName, setRecruiterName] = useState("");
const [recruiterEmail, setRecruiterEmail] = useState("");
@@ -60,14 +71,14 @@ export default function CompaniesTable() {
},
{
initialData: [],
errorMessage: t("companiesLoadFailed"),
deps: [t],
errorMessage: "",
},
);
const companies = companiesResource.data;
useEffect(() => {
if (savePending.current) return;
const params = new URLSearchParams(location.search);
const editId = Number(params.get("edit") || 0);
if (!editId || companies.length === 0) return;
@@ -75,11 +86,22 @@ export default function CompaniesTable() {
if (!company) return;
openEdit(company);
params.delete("edit");
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true });
}, [companies, location.pathname, location.search, navigate]);
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "", hash: location.hash }, { replace: true, state: location.state });
}, [companies, location.pathname, location.search, location.hash, location.state, navigate, saving]);
useEffect(() => () => { relationshipSequence.current += 1; }, []);
useEffect(() => {
// Restore after MUI has removed the exiting modal.
if (focusReturn?.id && document.activeElement === document.body) {
containerRef.current?.querySelector<HTMLButtonElement>(`[data-company-${focusReturn.action}="${focusReturn.id}"]`)?.focus();
}
}, [focusReturn]);
const openEdit = (c: Company) => {
setEditError("");
if (savePending.current) return;
editCompanyIdRef.current = c.id;
setEditError(null);
setEditing(c);
setRecruiterName(c.recruiterName ?? "");
setRecruiterEmail(c.recruiterEmail ?? "");
@@ -91,22 +113,40 @@ export default function CompaniesTable() {
};
const openRelationship = async (company: Company) => {
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null);
const request = ++relationshipSequence.current;
relationshipPending.current = true;
setRelationshipCompany(company);
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(null); setRelationship(null);
try {
const response = await api.get<CompanyRelationship>(`/companies/${company.id}/relationship`);
setRelationship(response.data);
if (request === relationshipSequence.current) setRelationship(response.data);
} catch (error) {
setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed")));
if (request !== relationshipSequence.current) return;
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
setRelationshipError({ kind: unauthorized ? "unauthorized" : "unavailable", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status });
} finally {
setRelationshipLoading(false);
if (request === relationshipSequence.current) {
relationshipPending.current = false;
setRelationshipLoading(false);
}
}
};
const closeRelationship = () => {
relationshipSequence.current += 1;
relationshipPending.current = false;
setRelationshipOpen(false);
};
const canSave = useMemo(() => !!editing?.id, [editing]);
const save = async () => {
if (!editing?.id) return;
setEditError("");
if (!editing?.id || savePending.current) return;
savePending.current = true;
editContentRef.current?.focus();
setSaving(true);
setEditError(null);
try {
const res = await api.put<Company>(`/companies/${editing.id}`, {
name: editing.name,
@@ -125,7 +165,10 @@ export default function CompaniesTable() {
setEditOpen(false);
setEditing(null);
} catch (error) {
setEditError(getApiErrorMessage(error, t("companiesUpdateFailed")));
setEditError(getApiErrorMessage(error, ""));
} finally {
savePending.current = false;
setSaving(false);
}
};
@@ -137,10 +180,10 @@ export default function CompaniesTable() {
);
return (
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
<Box ref={containerRef} sx={{ minWidth: 0 }}>
<ViewStateNotice
loading={companiesResource.loading}
error={companiesResource.error}
error={companiesResource.error ? { ...companiesResource.error, message: companiesResource.error.message || t("companiesLoadFailed") } : null}
title={t("companiesLoadFailed")}
description={t("companiesLoadFailedBody")}
onRetry={companiesResource.reload}
@@ -150,17 +193,17 @@ export default function CompaniesTable() {
isMobile ? (
<Stack spacing={1.5}>
{companies.map((c) => (
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
<Paper key={c.id} component="article" aria-label={c.name} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0 }}>
<Stack spacing={1.25}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
<Box>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography component="h2" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{c.name}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
</Box>
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<IconButton size="small" data-company-edit={c.id} aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<IconButton size="small" data-company-relationship={c.id} aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<PeopleAltOutlinedIcon fontSize="small" />
</IconButton>
</Box>
@@ -181,8 +224,8 @@ export default function CompaniesTable() {
) : null}
</Stack>
) : (
<TableContainer sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Table>
<TableContainer component={Paper} variant="outlined" role="region" aria-label={t("companies")} tabIndex={0} sx={{ maxWidth: "100%", overflow: "auto" }}>
<Table aria-label={t("companies")} sx={{ minWidth: 840, "& th, & td": { overflowWrap: "anywhere" }, "& thead th": { whiteSpace: "nowrap" } }}>
<TableHead>
<TableRow>
<TableCell>{t("companiesName")}</TableCell>
@@ -191,26 +234,26 @@ export default function CompaniesTable() {
<TableCell>{t("companiesPipeline")}</TableCell>
<TableCell>{t("companiesRecruiter")}</TableCell>
<TableCell>{t("companiesNextContact")}</TableCell>
<TableCell width={1} align="right" />
<TableCell width={1} align="right" sx={{ whiteSpace: "nowrap" }}>{t("jobTableActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.name}</TableCell>
<TableCell component="th" scope="row">{c.name}</TableCell>
<TableCell>{c.location ?? ""}</TableCell>
<TableCell>{c.source ?? ""}</TableCell>
<TableCell>{c.pipelineStage ?? ""}</TableCell>
<TableCell sx={{ minWidth: 110 }}>{c.source ?? ""}</TableCell>
<TableCell sx={{ minWidth: 140 }}>{c.pipelineStage ?? ""}</TableCell>
<TableCell>
{c.recruiterName ?? ""}
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
</TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
<TableCell align="right">
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<TableCell sx={{ whiteSpace: "nowrap" }}>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
<IconButton size="small" data-company-relationship={c.id} aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
<PeopleAltOutlinedIcon fontSize="small" />
</IconButton>
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<IconButton size="small" data-company-edit={c.id} aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</TableCell>
@@ -231,45 +274,54 @@ export default function CompaniesTable() {
)
) : null}
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
<Dialog open={editOpen} onClose={() => { if (!savePending.current) setEditOpen(false); }} fullWidth fullScreen={isMobile} maxWidth="sm"
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "edit", id: editCompanyIdRef.current }) } }}>
<DialogTitle>{t("companiesEdit")}</DialogTitle>
<DialogContent>
{editError ? <Alert severity="error" sx={{ mt: 1 }}>{editError}</Alert> : null}
<DialogContent ref={editContentRef} role="region" aria-label={t("companiesEdit")} tabIndex={-1} aria-busy={saving}>
{saving ? <ViewStateNotice compact loading title={t("companiesUpdateFailed")} /> : null}
{editError !== null ? <Alert severity="error" sx={{ mt: 1 }}>{editError || t("companiesUpdateFailed")}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField
label={t("companiesName")}
disabled={saving}
value={editing?.name ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/>
<TextField
label={t("companiesLocation")}
disabled={saving}
value={editing?.location ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
/>
<TextField
label={t("companiesSource")}
disabled={saving}
value={editing?.source ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
/>
<TextField
label={t("companiesPipelineStage")}
disabled={saving}
value={pipelineStage}
onChange={(e) => setPipelineStage(e.target.value)}
/>
<TextField
label={t("companiesRecruiterName")}
disabled={saving}
value={recruiterName}
onChange={(e) => setRecruiterName(e.target.value)}
/>
<TextField
label={t("companiesRecruiterEmail")}
disabled={saving}
value={recruiterEmail}
onChange={(e) => setRecruiterEmail(e.target.value)}
/>
<TextField
label={t("companiesRecruiterLinkedIn")}
disabled={saving}
value={recruiterLinkedIn}
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
@@ -277,6 +329,7 @@ export default function CompaniesTable() {
<TextField
label={t("companiesLastContacted")}
disabled={saving}
type="date"
value={lastContactedAt}
onChange={(e) => setLastContactedAt(e.target.value)}
@@ -284,6 +337,7 @@ export default function CompaniesTable() {
/>
<TextField
label={t("companiesNextContactField")}
disabled={saving}
type="date"
value={nextContactAt}
onChange={(e) => setNextContactAt(e.target.value)}
@@ -292,18 +346,24 @@ export default function CompaniesTable() {
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
<Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
<Button onClick={() => { if (!savePending.current) setEditOpen(false); }} disabled={saving} fullWidth={isMobile}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave || saving} fullWidth={isMobile}>
{t("save")}
</Button>
</DialogActions>
</Dialog>
<Dialog open={relationshipOpen} onClose={() => setRelationshipOpen(false)} fullWidth fullScreen={isMobile} maxWidth="md">
<DialogTitle>{relationship?.company.name ?? t("companiesRelationship")}</DialogTitle>
<DialogContent>
{relationshipLoading ? <Typography color="text.secondary">{t("loading")}</Typography> : null}
{relationshipError ? <Alert severity="error">{relationshipError}</Alert> : null}
<Dialog open={relationshipOpen} onClose={closeRelationship} fullWidth fullScreen={isMobile} maxWidth="md"
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "relationship", id: relationshipCompany?.id }) } }}>
<DialogTitle>{relationship?.company.name ?? relationshipCompany?.name ?? t("companiesRelationship")}</DialogTitle>
<DialogContent ref={relationshipContentRef} role="region" aria-label={t("companiesRelationship")} tabIndex={-1} aria-busy={relationshipLoading}>
<ViewStateNotice compact loading={relationshipLoading} title={t("companiesRelationshipLoadFailed")}
error={relationshipError ? { ...relationshipError, message: relationshipError.message || t("companiesRelationshipLoadFailed") } : null}
onRetry={() => {
if (!relationshipCompany || relationshipPending.current) return;
relationshipContentRef.current?.focus();
void openRelationship(relationshipCompany);
}} />
{relationship ? (
<Stack spacing={2} sx={{ mt: 0.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1.5 }}>
@@ -312,21 +372,21 @@ export default function CompaniesTable() {
{renderCompanyMeta(t("companiesNextContact"), relationship.company.nextContactAt ? new Date(relationship.company.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesApplications")}</Typography>
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("companiesApplications")}</Typography>
{relationship.applications.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoApplications")}</Typography> : (
<Stack spacing={1}>{relationship.applications.map((application) => (
<Paper key={application.id} variant="outlined" sx={{ p: 1.25, display: "flex", flexWrap: "wrap", gap: 1, alignItems: "center" }}>
<Box sx={{ flex: "1 1 220px" }}><Typography sx={{ fontWeight: 700 }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{application.status} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
<Button size="small" onClick={() => { setRelationshipOpen(false); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
<Box sx={{ flex: "1 1 220px", minWidth: 0 }}><Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{statusLabel(t, application.status)} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
<Button size="small" onClick={() => { closeRelationship(); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
</Paper>
))}</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesRecentContact")}</Typography>
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("companiesRecentContact")}</Typography>
{relationship.recentContacts.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoContact")}</Typography> : (
<Stack spacing={1}>{relationship.recentContacts.map((contact) => (
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider" }}>
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider", overflowWrap: "anywhere" }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{contact.subject || contact.channel || t("companiesContact")}</Typography>
<Typography variant="caption" color="text.secondary">{contact.jobTitle} · {new Date(contact.date).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} · {contact.from}</Typography>
</Box>
@@ -336,9 +396,9 @@ export default function CompaniesTable() {
</Stack>
) : null}
</DialogContent>
<DialogActions><Button onClick={() => setRelationshipOpen(false)}>{t("close")}</Button></DialogActions>
<DialogActions><Button onClick={closeRelationship}>{t("close")}</Button></DialogActions>
</Dialog>
</Paper>
</Box>
);
}
@@ -13,7 +13,7 @@ function Actions() {
return <><button onClick={() => toast("Saved", "success")}>Show feedback</button><button onClick={() => setLanguage("nb")}>Change language</button></>;
}
test("feedback does not reload company data; changing locale still reloads translated states", async () => {
test("feedback and locale changes translate company presentation without reloading company data", async () => {
localStorage.clear();
(api.get as jest.Mock).mockResolvedValue({ data: [{ id: 3, name: "Acme" }] });
render(<ToastProvider><I18nProvider><MemoryRouter><Actions /><CompaniesTable /></MemoryRouter></I18nProvider></ToastProvider>);
@@ -25,5 +25,5 @@ test("feedback does not reload company data; changing locale still reloads trans
expect(reads()).toBe(1);
fireEvent.click(screen.getByRole("button", { name: "Change language" }));
await screen.findByRole("button", { name: "Rediger: Acme" });
await waitFor(() => expect(reads()).toBe(2));
await waitFor(() => expect(reads()).toBe(1));
});