fix(ux): preserve localized save errors and current language retries

This commit is contained in:
cesnimda
2026-10-11 03:05:33 +02:00
parent a94ea0a4a4
commit c135d03602
15 changed files with 485 additions and 11 deletions
@@ -0,0 +1,24 @@
import { AxiosError, CanceledError } from "axios";
const { getApiErrorMessage } = jest.requireActual<typeof import("./api")>("./api");
test.each(["Failed to update company.", "Kunne ikke oppdatere selskap."])("transport failures use the caller's localized fallback: %s", (fallback) => {
for (const message of ["Request failed with status code 500", "Network Error", "timeout of 10000ms exceeded"]) {
expect(getApiErrorMessage(new AxiosError(message), fallback)).toBe(fallback);
}
});
test("structured validation and sanitized server explanations remain available", () => {
for (const data of [{ detail: "Review the company name." }, { errors: { name: ["Review the company name."] } }]) {
expect(getApiErrorMessage(Object.assign(new AxiosError("Request failed with status code 400"), { response: { data } }), "Localized fallback")).toBe("Review the company name.");
}
expect(getApiErrorMessage({ response: { data: "<html>proxy error</html>" } }, "Localized fallback")).toBe("Localized fallback");
});
test("explicit session cancellation explanations remain intact", () => {
expect(getApiErrorMessage(new CanceledError("This page belongs to an earlier session."), "Localized fallback")).toBe("This page belongs to an earlier session.");
});
test("ordinary application errors retain their explanation", () => {
expect(getApiErrorMessage(new Error("The draft changed."), "Localized fallback")).toBe("The draft changed.");
});
+1
View File
@@ -32,6 +32,7 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") {
if (typeof value === "string" && value.trim()) return sanitizeServerMessage(value, fallback);
}
}
if (axios.isAxiosError(error) && !axios.isCancel(error)) return fallback;
if (typeof error?.message === "string" && error.message.trim()) return sanitizeServerMessage(error.message, fallback);
return fallback;
}
@@ -1,6 +1,6 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
import CompaniesTable from "./components/CompaniesTable";
@@ -13,6 +13,12 @@ jest.mock("./api", () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
mockedApi.get.mockReset();
mockedApi.put.mockReset();
window.localStorage.clear();
});
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") ? {
@@ -29,3 +35,40 @@ test("opens one company relationship view with applications and recent contact",
expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship");
});
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 }] });
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);
});
test.each([
{ language: "en", edit: "Edit: Acme", name: "Name", save: "Save", failure: "Failed to update company." },
{ language: "nb", edit: "Rediger: Acme", name: "Navn", save: "Lagre", failure: "Kunne ikke oppdatere selskap." },
])("[PRES-J08 E08] $language company save failure remains inside the dialog with the draft available for retry", async ({ language, edit, name, save, failure }) => {
window.localStorage.setItem("uiLanguage", language);
const company = { id: 3, name: "Acme", location: "Oslo", source: "User", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
mockedApi.get.mockResolvedValue({ data: [company] });
mockedApi.put.mockRejectedValueOnce(new Error("offline"));
mockedApi.put.mockResolvedValueOnce({ data: { ...company, name: "Acme updated" } });
render(<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
fireEvent.click(await screen.findByRole("button", { name: edit }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name }), { target: { value: "Acme updated" } });
fireEvent.click(within(dialog).getByRole("button", { name: save }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent(failure);
expect(within(dialog).getByRole("textbox", { name })).toHaveValue("Acme updated");
expect(dialog).toBeVisible();
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/companies/3", expect.objectContaining({ name: "Acme updated" }));
fireEvent.click(within(dialog).getByRole("button", { name: save }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
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" }));
});
@@ -39,6 +39,7 @@ export default function CompaniesTable() {
const location = useLocation();
const navigate = useNavigate();
const [editOpen, setEditOpen] = useState(false);
const [editError, setEditError] = useState("");
const [editing, setEditing] = useState<Company | null>(null);
const [relationshipOpen, setRelationshipOpen] = useState(false);
const [relationshipLoading, setRelationshipLoading] = useState(false);
@@ -78,6 +79,7 @@ export default function CompaniesTable() {
}, [companies, location.pathname, location.search, navigate]);
const openEdit = (c: Company) => {
setEditError("");
setEditing(c);
setRecruiterName(c.recruiterName ?? "");
setRecruiterEmail(c.recruiterEmail ?? "");
@@ -104,6 +106,7 @@ export default function CompaniesTable() {
const save = async () => {
if (!editing?.id) return;
setEditError("");
try {
const res = await api.put<Company>(`/companies/${editing.id}`, {
name: editing.name,
@@ -122,7 +125,7 @@ export default function CompaniesTable() {
setEditOpen(false);
setEditing(null);
} catch (error) {
toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error");
setEditError(getApiErrorMessage(error, t("companiesUpdateFailed")));
}
};
@@ -166,7 +169,7 @@ export default function CompaniesTable() {
{renderCompanyMeta(t("companiesSource"), c.source)}
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)}
</Box>
</Stack>
</Paper>
@@ -202,7 +205,7 @@ export default function CompaniesTable() {
{c.recruiterName ?? ""}
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
</TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</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)}>
<PeopleAltOutlinedIcon fontSize="small" />
@@ -231,6 +234,7 @@ export default function CompaniesTable() {
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
<DialogTitle>{t("companiesEdit")}</DialogTitle>
<DialogContent>
{editError ? <Alert severity="error" sx={{ mt: 1 }}>{editError}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField
label={t("companiesName")}
+2 -2
View File
@@ -517,7 +517,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>{t("jobTableCompany")} {companyFilterId}</MenuItem> : null}
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
@@ -598,7 +598,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>{t("jobTableCompany")} {companyFilterId}</MenuItem> : null}
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
+51 -1
View File
@@ -1,9 +1,10 @@
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, useI18n } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
function Probe() {
const { language, setLanguage, hydrateLanguage, t } = useI18n();
@@ -44,3 +45,52 @@ test("hydrates an authenticated server preference without writing it back", () =
expect(window.localStorage.getItem("uiLanguage")).toBe("nb");
expect(api.put).not.toHaveBeenCalled();
});
test.each([
["Norsk", "nb", "nb-NO", "Språket er endret på denne enheten, men kunne ikke lagres på kontoen din.", "Prøv igjen"],
["English", "en", "en", "The language changed on this device, but your account preference could not be saved.", "Retry"],
])("a failed account language save keeps the local choice and offers a localized retry: %s", async (button, local, remote, message, retry) => {
(api.put as jest.Mock).mockRejectedValueOnce({ response: { status: 500, data: "Internal database detail" } });
render(<ToastProvider><I18nProvider><Probe /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: button }));
expect(screen.getByLabelText("language")).toHaveTextContent(local);
expect(window.localStorage.getItem("uiLanguage")).toBe(local);
expect(document.documentElement.lang).toBe(remote);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(message);
expect(alert).not.toHaveTextContent("Internal database detail");
expect(api.put).toHaveBeenCalledTimes(1);
expect(api.put).toHaveBeenLastCalledWith("/auth/preferences/language", { language: remote });
fireEvent.click(within(alert).getByRole("button", { name: retry }));
await waitFor(() => expect(api.put).toHaveBeenCalledTimes(2));
expect(api.put).toHaveBeenLastCalledWith("/auth/preferences/language", { language: remote });
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByLabelText("language")).toHaveTextContent(local);
});
test("a delayed language-save failure cannot offer a retry for a newer successful choice", async () => {
let rejectOld!: (error: Error) => void;
(api.put as jest.Mock).mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject; }));
render(<ToastProvider><I18nProvider><Probe /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Norsk" }));
fireEvent.click(screen.getByRole("button", { name: "English" }));
await act(async () => { rejectOld(new Error("offline")); });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByLabelText("language")).toHaveTextContent("en");
expect(api.put).toHaveBeenCalledTimes(2);
});
test("a previously delivered language retry cannot reverse a newer choice", async () => {
(api.put as jest.Mock).mockRejectedValueOnce(new Error("offline"));
render(<ToastProvider><I18nProvider><Probe /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Norsk" }));
const alert = await screen.findByRole("alert");
const retry = within(alert).getByRole("button", { name: "Prøv igjen" });
fireEvent.click(screen.getByRole("button", { name: "English" }));
fireEvent.click(retry);
expect(screen.getByLabelText("language")).toHaveTextContent("en");
expect(window.localStorage.getItem("uiLanguage")).toBe("en");
expect(api.put).toHaveBeenCalledTimes(2);
});
+16 -2
View File
@@ -1,6 +1,7 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { translations, TranslationKey, UiLanguage } from "./translations";
import { api } from "../api";
import { useOptionalToast } from "../toast";
type TranslationParams = Record<string, string | number>;
@@ -19,12 +20,16 @@ function interpolate(template: string, params?: TranslationParams) {
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const feedback = useOptionalToast();
const [language, setLanguageState] = useState<UiLanguage>(() => {
const raw = window.localStorage.getItem("uiLanguage");
return raw === "nb" || raw === "nb-NO" || raw === "no" ? "nb" : "en";
});
const currentLanguage = useRef(language);
const languageSaveSequence = useRef(0);
const applyLanguage = useCallback((l: UiLanguage) => {
currentLanguage.current = l;
setLanguageState(l);
window.localStorage.setItem("uiLanguage", l);
document.documentElement.lang = l === "nb" ? "nb-NO" : "en";
@@ -32,7 +37,16 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
const setLanguage = (l: UiLanguage) => {
applyLanguage(l);
void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => undefined);
const requestSequence = ++languageSaveSequence.current;
void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => {
if (requestSequence !== languageSaveSequence.current || currentLanguage.current !== l) return;
feedback?.toast(translations[l].settingsLanguageSaveFailed, "error", {
label: translations[l].retry,
onClick: () => {
if (requestSequence === languageSaveSequence.current && currentLanguage.current === l) setLanguage(l);
},
});
});
};
const hydrateLanguage = useCallback((value?: string | null) => {
+2
View File
@@ -1149,6 +1149,7 @@ export const translations = {
settingsLanguageTitle: "Language and localization",
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
settingsPreferredLanguage: "Preferred language",
settingsLanguageSaveFailed: "The language changed on this device, but your account preference could not be saved.",
settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål",
settingsJobs: "Jobs",
@@ -3732,6 +3733,7 @@ export const translations = {
settingsLanguageTitle: "Språk og lokalisering",
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
settingsPreferredLanguage: "Foretrukket språk",
settingsLanguageSaveFailed: "Språket er endret på denne enheten, men kunne ikke lagres på kontoen din.",
settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål",
settingsJobs: "Jobber",
+4
View File
@@ -101,3 +101,7 @@ export function useToast(): ToastApi {
if (!v) throw new Error("useToast must be used within ToastProvider");
return v;
}
export function useOptionalToast() {
return useContext(ToastContext);
}