feat(housekeeping): add localized route states
CI / check (pull_request) Successful in 30s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-31 18:45:46 +02:00
1 parent 2ed00bb949
commit 19fb8be7fb
12 files changed
+468 -30

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "@/components/link";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
interface HousekeepingErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function HousekeepingError({
error,
reset,
}: HousekeepingErrorProps) {
const translate = useTranslations("pages.housekeeping");
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-4 py-12">
<div className="w-full">
<HousekeepingPageState
state="error"
headingLevel="h1"
title={translate("states.error.title")}
description={translate("states.error.description")}
retryAction={
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={reset}
className="rounded-md bg-[var(--admin-accent)] px-3 py-2 text-sm font-medium text-[var(--admin-accent-foreground)]"
>
{translate("states.retry")}
</button>
<Link
href="/ase-next"
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text)] hover:bg-[var(--admin-canvas)]"
>
{translate("states.backToHousekeeping")}
</Link>
</div>
}
/>
{error.digest ? (
<p className="mt-3 text-xs text-[var(--admin-text-muted)]">
{translate("states.supportReference", {
reference: error.digest,
})}
</p>
) : null}
</div>
</main>
);
}
+17 -21
View File
@@ -1,32 +1,28 @@
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
export default async function HousekeepingForbidden() {
const translate = await getTranslations("pages.housekeeping");
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-4 py-12">
<section
role="alert"
aria-labelledby="housekeeping-forbidden-title"
className="w-full rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-6"
>
<h1
id="housekeeping-forbidden-title"
className="text-xl font-semibold text-[var(--admin-text)]"
>
{translate("states.forbidden.title")}
</h1>
<p className="mt-2 text-sm text-[var(--admin-text-muted)]">
{translate("states.forbidden.description")}
</p>
<Link
href="/"
className="mt-5 inline-flex rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text)] hover:bg-[var(--admin-canvas)]"
>
{translate("preview.backToSite")}
</Link>
</section>
<div className="w-full">
<HousekeepingPageState
state="forbidden"
headingLevel="h1"
title={translate("states.forbidden.title")}
description={translate("states.forbidden.description")}
retryAction={
<Link
href="/"
className="inline-flex rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text)] hover:bg-[var(--admin-canvas)]"
>
{translate("states.backToSite")}
</Link>
}
/>
</div>
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { getTranslations } from "next-intl/server";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
export default async function HousekeepingLoading() {
const translate = await getTranslations("pages.housekeeping");
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-4 py-12">
<div className="w-full">
<HousekeepingPageState
state="loading"
headingLevel="h1"
title={translate("states.loading.title")}
description={translate("states.loading.description")}
/>
</div>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { env } from "@/env";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
export default async function HousekeepingNotFound() {
const translate = await getTranslations("pages.housekeeping");
const previewEnabled = isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
});
const returnHref = previewEnabled ? "/ase-next" : "/";
const returnLabel = previewEnabled
? translate("states.backToHousekeeping")
: translate("states.backToSite");
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-4 py-12">
<div className="w-full">
<HousekeepingPageState
state="not-found"
headingLevel="h1"
title={translate("states.notFound.title")}
description={translate("states.notFound.description")}
retryAction={
<Link
href={returnHref}
className="inline-flex rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text)] hover:bg-[var(--admin-canvas)]"
>
{returnLabel}
</Link>
}
/>
</div>
</main>
);
}
@@ -0,0 +1,132 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const boundaryMocks = vi.hoisted(() => {
const messages: Record<string, string> = {
"states.loading.title": "HK::loading-title",
"states.loading.description": "HK::loading-description",
"states.notFound.title": "HK::not-found-title",
"states.notFound.description": "HK::not-found-description",
"states.error.title": "HK::error-title",
"states.error.description": "HK::error-description",
"states.retry": "HK::retry",
"states.backToSite": "HK::back-to-site",
"states.backToHousekeeping": "HK::back-to-housekeeping",
};
return {
env: {
NODE_ENV: "test" as "development" | "test" | "production",
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
},
translate: vi.fn(
(key: string, values?: Record<string, string | number>) => {
if (key === "states.supportReference") {
return `HK::support-reference:${values?.reference}`;
}
const message = messages[key];
if (message === undefined) {
throw new Error(`Unexpected translation: ${key}`);
}
return message;
},
),
};
});
vi.mock("@/env", () => ({ env: boundaryMocks.env }));
vi.mock("next-intl", () => ({
useTranslations: () => boundaryMocks.translate,
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(async () => boundaryMocks.translate),
}));
import HousekeepingRouteLoading from "@/app/ase-next/[domain]/[[...segments]]/loading";
import HousekeepingError from "@/app/ase-next/error";
import HousekeepingLoading from "@/app/ase-next/loading";
import HousekeepingNotFound from "@/app/ase-next/not-found";
function findElementByType(
node: ReactNode,
type: string,
): ReactElement<Record<string, unknown>> | undefined {
if (!isValidElement(node)) return undefined;
if (node.type === type) {
return node as ReactElement<Record<string, unknown>>;
}
for (const value of Object.values(node.props as Record<string, unknown>)) {
const candidates = Array.isArray(value) ? value : [value];
for (const candidate of candidates) {
const match = findElementByType(candidate as ReactNode, type);
if (match) return match;
}
}
return undefined;
}
describe("/ase-next App Router boundaries", () => {
beforeEach(() => {
vi.clearAllMocks();
boundaryMocks.env.NODE_ENV = "test";
boundaryMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
});
it.each([
["root", () => HousekeepingLoading()],
["route", () => HousekeepingRouteLoading()],
] as const)(
"renders the localized %s loading boundary",
async (_scope, view) => {
const html = renderToStaticMarkup(await view());
expect(html).toContain("HK::loading-title");
expect(html).toContain("HK::loading-description");
expect(html).toContain('aria-live="polite"');
},
);
it("offers a safe housekeeping return for a missing preview route", async () => {
const html = renderToStaticMarkup(await HousekeepingNotFound());
expect(html).toContain("HK::not-found-title");
expect(html).toContain("HK::not-found-description");
expect(html).toContain('href="/ase-next"');
expect(html).toContain("HK::back-to-housekeeping");
});
it("returns to the site when the preview itself is unavailable", async () => {
boundaryMocks.env.NODE_ENV = "production";
boundaryMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = false;
const html = renderToStaticMarkup(await HousekeepingNotFound());
expect(html).toContain('href="/"');
expect(html).toContain("HK::back-to-site");
expect(html).not.toContain('href="/ase-next"');
});
it("renders only a safe digest and wires the retry action", () => {
const reset = vi.fn();
const error = Object.assign(new Error("database password leaked"), {
digest: "digest-123",
});
const view = HousekeepingError({ error, reset });
const html = renderToStaticMarkup(view);
expect(html).toContain("HK::error-title");
expect(html).toContain("HK::error-description");
expect(html).toContain("HK::support-reference:digest-123");
expect(html).not.toContain("database password leaked");
const button = findElementByType(view, "button");
expect(button).toBeDefined();
expect(button?.props.onClick).toBe(reset);
(button?.props.onClick as (() => void) | undefined)?.();
expect(reset).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
import nlMessages from "@/messages/nl.json";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
const requiredKeys = [
@@ -21,6 +22,18 @@ const requiredKeys = [
"pages.housekeeping.states.error.description",
"pages.housekeeping.states.forbidden.title",
"pages.housekeeping.states.forbidden.description",
"pages.housekeeping.states.conflict.title",
"pages.housekeeping.states.conflict.description",
"pages.housekeeping.states.dependency.title",
"pages.housekeeping.states.dependency.description",
"pages.housekeeping.states.notFound.title",
"pages.housekeeping.states.notFound.description",
"pages.housekeeping.states.success.title",
"pages.housekeeping.states.success.description",
"pages.housekeeping.states.retry",
"pages.housekeeping.states.backToSite",
"pages.housekeeping.states.backToHousekeeping",
"pages.housekeeping.states.supportReference",
];
const expectedDomainMessages = [
@@ -68,6 +81,7 @@ describe("housekeeping localization contract", () => {
it.each([
["English", en],
["Italian", itMessages],
["Dutch", nlMessages],
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
const housekeeping = resolveMessage(messages, "pages.housekeeping");
@@ -115,4 +129,12 @@ describe("housekeeping localization contract", () => {
),
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
});
it("uses idiomatic Dutch copy for access and missing states", () => {
expect(
resolveMessage(nlMessages, "pages.housekeeping.states.forbidden.title"),
).toBe("Toegang geweigerd");
expect(
resolveMessage(nlMessages, "pages.housekeeping.states.notFound.title"),
).toBe("Pagina niet gevonden");
});
});
@@ -10,7 +10,12 @@ describe("HousekeepingPageState", () => {
["loading", "status"],
["empty", "status"],
["partial", "status"],
["conflict", "alert"],
["dependency", "alert"],
["forbidden", "alert"],
["error", "alert"],
["not-found", "status"],
["success", "status"],
] as const)("renders %s with the %s role", (state, role) => {
const pageState =
state === "partial" ? (
@@ -46,6 +51,20 @@ describe("HousekeepingPageState", () => {
expect(loading).toContain('aria-live="polite"');
});
it("supports a page-level heading for router boundaries", () => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state="not-found"
headingLevel="h1"
title="Missing page"
description="The route is unavailable"
/>,
);
expect(html).toContain("<h1");
expect(html).toContain(">Missing page</h1>");
});
it.each([
["English", en.pages.housekeeping.states.partial],
["Italian", itMessages.pages.housekeeping.states.partial],
@@ -3,35 +3,65 @@ import type { ReactNode } from "react";
interface HousekeepingPageStateBaseProps {
title: string;
description: string;
headingLevel?: "h1" | "h2";
retryAction?: ReactNode;
}
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
| {
state:
| "loading"
| "empty"
| "conflict"
| "dependency"
| "forbidden"
| "error"
| "not-found"
| "success";
partialLabel?: never;
}
);
const ALERT_STATES = new Set(["conflict", "dependency", "forbidden", "error"]);
export function HousekeepingPageState({
state,
partialLabel,
title,
description,
headingLevel = "h2",
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isAlert = ALERT_STATES.has(state);
const isPartial = state === "partial";
const isWarning = isPartial || state === "conflict" || state === "dependency";
const isError = state === "error" || state === "forbidden";
const isSuccess = state === "success";
const Heading = headingLevel;
return (
<section
role={isError ? "alert" : "status"}
role={isAlert ? "alert" : "status"}
data-state-tone={
isError
? "error"
: isWarning
? "warning"
: isSuccess
? "success"
: "neutral"
}
aria-live={state === "loading" ? "polite" : undefined}
className={`rounded-lg border p-4 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
: isPartial
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
: isWarning
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
: isSuccess
? "border-[var(--admin-success)] bg-[var(--admin-success-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
}`}
>
{isPartial ? (
@@ -42,7 +72,9 @@ export function HousekeepingPageState({
{partialLabel}
</p>
) : null}
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
<Heading className="font-medium text-[var(--admin-text)]">
{title}
</Heading>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{description}
</p>
@@ -28,6 +28,7 @@ const routeMocks = vi.hoisted(() => {
"domains.economy.description": "Localized Economy description",
"states.forbidden.title": "HK::access-denied",
"states.forbidden.description": "Localized insufficient access",
"states.backToSite": "HK::back-to-site",
"states.empty.title": "Localized empty title",
"states.empty.description": "Localized empty description",
};
@@ -196,9 +197,13 @@ import AdminNextPage from "@/app/ase-next/page";
const routeFiles = [
"src/app/ase-next/layout.tsx",
"src/app/ase-next/error.tsx",
"src/app/ase-next/forbidden.tsx",
"src/app/ase-next/loading.tsx",
"src/app/ase-next/not-found.tsx",
"src/app/ase-next/page.tsx",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
] as const;
+21 -1
View File
@@ -3322,7 +3322,27 @@
"forbidden": {
"title": "Access denied",
"description": "Your account does not have permission to use this housekeeping area."
}
},
"conflict": {
"title": "Update conflict",
"description": "This information changed while you were working. Reload it before trying again."
},
"dependency": {
"title": "Service unavailable",
"description": "A required housekeeping service is unavailable. Try again later."
},
"notFound": {
"title": "Page not found",
"description": "This housekeeping route does not exist or is no longer available."
},
"success": {
"title": "Changes saved",
"description": "The housekeeping operation completed successfully."
},
"retry": "Try again",
"backToSite": "Back to site",
"backToHousekeeping": "Back to housekeeping",
"supportReference": "Support reference: {reference}"
}
}
}
+21 -1
View File
@@ -3321,7 +3321,27 @@
"forbidden": {
"title": "Accesso negato",
"description": "Il tuo account non dispone dei permessi necessari per usare questa area di housekeeping."
}
},
"conflict": {
"title": "Conflitto di aggiornamento",
"description": "Queste informazioni sono cambiate mentre lavoravi. Ricaricale prima di riprovare."
},
"dependency": {
"title": "Servizio non disponibile",
"description": "Un servizio necessario per housekeeping non è disponibile. Riprova più tardi."
},
"notFound": {
"title": "Pagina non trovata",
"description": "Questa pagina housekeeping non esiste o non è più disponibile."
},
"success": {
"title": "Modifiche salvate",
"description": "L’operazione housekeeping è stata completata correttamente."
},
"retry": "Riprova",
"backToSite": "Torna al sito",
"backToHousekeeping": "Torna a housekeeping",
"supportReference": "Riferimento assistenza: {reference}"
}
}
}
+81
View File
@@ -3168,6 +3168,87 @@
"teamHint": "Online staff & werklast"
}
},
"housekeeping": {
"preview": {
"badge": "Voorbeeld van de basis",
"commandDisabled": "Zoeken en opdrachten worden in een later deelproject ingeschakeld.",
"backToSite": "Terug naar de website"
},
"navigation": {
"skipToContent": "Naar inhoud",
"primary": "Housekeeping-domeinen",
"contextual": "Domeinnavigatie"
},
"domains": {
"operations": {
"title": "Operaties",
"description": "Operationeel overzicht en dagelijkse wachtrijen"
},
"people": {
"title": "Gebruikers",
"description": "Gebruikers, moderatie en ondersteuning"
},
"content": {
"title": "Inhoud",
"description": "Redactionele inhoud en betrokkenheid van de community"
},
"economy": {
"title": "Economie",
"description": "Catalogus- en winkelbeheer"
},
"hotel": {
"title": "Hotel",
"description": "Kamers, radio en hulpmiddelen voor assets"
},
"system": {
"title": "Systeem",
"description": "Configuratie, observatie en toegang"
}
},
"states": {
"loading": {
"title": "Housekeeping laden",
"description": "De beschikbare housekeeping-tools worden voorbereid."
},
"empty": {
"title": "Niets beschikbaar",
"description": "Er is geen housekeeping-inhoud beschikbaar voor dit domein."
},
"partial": {
"label": "Gedeeltelijke gegevens",
"title": "Sommige informatie is niet beschikbaar",
"description": "Controleer de beschikbare informatie en probeer het later opnieuw."
},
"error": {
"title": "Housekeeping kan niet worden geladen",
"description": "Probeer het later opnieuw of neem contact op met een beheerder."
},
"forbidden": {
"title": "Toegang geweigerd",
"description": "Je account heeft geen toestemming om dit housekeeping-onderdeel te gebruiken."
},
"conflict": {
"title": "Wijzigingsconflict",
"description": "Deze informatie is tijdens je werk gewijzigd. Laad de gegevens opnieuw voordat je het nogmaals probeert."
},
"dependency": {
"title": "Dienst niet beschikbaar",
"description": "Een vereiste housekeeping-dienst is niet beschikbaar. Probeer het later opnieuw."
},
"notFound": {
"title": "Pagina niet gevonden",
"description": "Deze housekeeping-pagina bestaat niet of is niet meer beschikbaar."
},
"success": {
"title": "Wijzigingen opgeslagen",
"description": "De housekeeping-bewerking is voltooid."
},
"retry": "Opnieuw proberen",
"backToSite": "Terug naar de website",
"backToHousekeeping": "Terug naar housekeeping",
"supportReference": "Referentie voor ondersteuning: {reference}"
}
},
"studio": {
"title": "Studio",
"sourceFurnidata": "{source} furnidata",