From 19fb8be7fb8dda272177964bb2afd07e51fca1f7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 31 Aug 2026 18:44:56 +0200 Subject: [PATCH] feat(housekeeping): add localized route states --- src/app/ase-next/error.tsx | 54 +++++++ src/app/ase-next/forbidden.tsx | 38 +++-- src/app/ase-next/loading.tsx | 19 +++ src/app/ase-next/not-found.tsx | 38 +++++ .../foundation/app-boundaries.test.tsx | 132 ++++++++++++++++++ .../foundation/localization-contract.test.ts | 22 +++ .../page/housekeeping-page-state.test.tsx | 19 +++ .../page/housekeeping-page-state.tsx | 46 +++++- .../foundation/preview-route-contract.test.ts | 5 + src/messages/en.json | 22 ++- src/messages/it.json | 22 ++- src/messages/nl.json | 81 +++++++++++ 12 files changed, 468 insertions(+), 30 deletions(-) create mode 100644 src/app/ase-next/error.tsx create mode 100644 src/app/ase-next/loading.tsx create mode 100644 src/app/ase-next/not-found.tsx create mode 100644 src/features/housekeeping/foundation/app-boundaries.test.tsx diff --git a/src/app/ase-next/error.tsx b/src/app/ase-next/error.tsx new file mode 100644 index 00000000..a2967501 --- /dev/null +++ b/src/app/ase-next/error.tsx @@ -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 ( +
+
+ + + + {translate("states.backToHousekeeping")} + +
+ } + /> + {error.digest ? ( +

+ {translate("states.supportReference", { + reference: error.digest, + })} +

+ ) : null} + +
+ ); +} diff --git a/src/app/ase-next/forbidden.tsx b/src/app/ase-next/forbidden.tsx index a5f5c374..7c12b2f9 100644 --- a/src/app/ase-next/forbidden.tsx +++ b/src/app/ase-next/forbidden.tsx @@ -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 (
-
-

- {translate("states.forbidden.title")} -

-

- {translate("states.forbidden.description")} -

- - {translate("preview.backToSite")} - -
+
+ + {translate("states.backToSite")} + + } + /> +
); } diff --git a/src/app/ase-next/loading.tsx b/src/app/ase-next/loading.tsx new file mode 100644 index 00000000..2f33f175 --- /dev/null +++ b/src/app/ase-next/loading.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/src/app/ase-next/not-found.tsx b/src/app/ase-next/not-found.tsx new file mode 100644 index 00000000..18b6ccfc --- /dev/null +++ b/src/app/ase-next/not-found.tsx @@ -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 ( +
+
+ + {returnLabel} + + } + /> +
+
+ ); +} diff --git a/src/features/housekeeping/foundation/app-boundaries.test.tsx b/src/features/housekeeping/foundation/app-boundaries.test.tsx new file mode 100644 index 00000000..2a8d4047 --- /dev/null +++ b/src/features/housekeeping/foundation/app-boundaries.test.tsx @@ -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 = { + "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) => { + 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> | undefined { + if (!isValidElement(node)) return undefined; + if (node.type === type) { + return node as ReactElement>; + } + + for (const value of Object.values(node.props as Record)) { + 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); + }); +}); diff --git a/src/features/housekeeping/foundation/localization-contract.test.ts b/src/features/housekeeping/foundation/localization-contract.test.ts index 82239e82..0f5c6397 100644 --- a/src/features/housekeeping/foundation/localization-contract.test.ts +++ b/src/features/housekeeping/foundation/localization-contract.test.ts @@ -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"); + }); }); diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx index 13c02396..da4cb683 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx @@ -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( + , + ); + + expect(html).toContain("Missing page"); + }); + it.each([ ["English", en.pages.housekeeping.states.partial], ["Italian", itMessages.pages.housekeeping.states.partial], diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx index e6cf52ed..bb5ec827 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx @@ -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 (
{isPartial ? ( @@ -42,7 +72,9 @@ export function HousekeepingPageState({ {partialLabel}

) : null} -

{title}

+ + {title} +

{description}

diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 2b745945..748d6443 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -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; diff --git a/src/messages/en.json b/src/messages/en.json index e2ac0ba0..c34dffbc 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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}" } } } diff --git a/src/messages/it.json b/src/messages/it.json index 45375e86..33a1f92f 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -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}" } } } diff --git a/src/messages/nl.json b/src/messages/nl.json index 7e00beb5..0e5df49a 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -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",