feat(housekeeping): localize content route headings
CI / check (pull_request) Failing after 9s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-31 21:07:17 +02:00
1 parent 43470ebf82
commit fc050bd3f8
14 files changed
+288 -30

No files matched your search

@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
@@ -9,6 +10,7 @@ import {
} from "./content-page-frame";
export function ContentBrandPage({
title,
context,
result,
routeId,
@@ -118,7 +120,7 @@ export function ContentBrandPage({
) : null;
return (
<ContentPageFrame
title="Brand"
title={title ?? "Brand"}
description="Manage theme, favicon, and logo assets."
result={result}
forms={forms}
@@ -128,6 +130,7 @@ export function ContentBrandPage({
export async function renderContentBrandPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Brand");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.brand.theme",
params: input.match.params,
@@ -135,6 +138,7 @@ export async function renderContentBrandPage(input: HousekeepingPageInput) {
});
return (
<ContentBrandPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -3,6 +3,7 @@ import type { HousekeepingResult } from "../../../foundation/contracts";
import type { ContentQueryData } from "../queries/content-queries";
export interface ContentPageProps {
readonly title?: string;
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: import("../routes").ContentRouteId | null;
@@ -0,0 +1,91 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { ok } from "../../../foundation/contracts";
import { contentQuery } from "../queries/content-queries";
import { renderContentBrandPage } from "./brand";
import { renderContentEditorialPage } from "./editorial";
import { renderContentEngagementPage } from "./engagement";
import { renderContentHelpPage } from "./help";
import { renderContentLocalizationPage } from "./localization";
import { renderContentMediaPage } from "./media";
vi.mock("../queries/content-queries", () => ({
contentQuery: { run: vi.fn() },
}));
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const cases = [
[
"content.editorial.article-create",
"/ase-next/content/editorial/articles/new",
"editorial",
"New article",
renderContentEditorialPage,
],
[
"content.media.ad-create",
"/ase-next/content/media/ads/new",
"media",
"New advertisement",
renderContentMediaPage,
],
[
"content.engagement.event-create",
"/ase-next/content/engagement/events/create",
"engagement",
"New event",
renderContentEngagementPage,
],
[
"content.help.question-create",
"/ase-next/content/help/questions/new",
"help",
"New help article",
renderContentHelpPage,
],
[
"content.brand.theme-builder",
"/ase-next/content/brand/theme-builder",
"brand",
"Theme builder",
renderContentBrandPage,
],
[
"content.localization.cms",
"/ase-next/content/localization/cms",
"localization",
"CMS translations",
renderContentLocalizationPage,
],
] as const;
it.each(cases)(
"renders localized title for %s",
async (routeId, canonicalHref, kind, title, renderPage) => {
vi.mocked(contentQuery.run).mockResolvedValue(
ok({ kind, items: [], total: 0, partialDependencies: [] }, routeId),
);
const page = await renderPage({
context,
match: {
routeId,
domain: "content",
params: {},
canonicalHref,
},
translate: (key) => (key === `routes.${routeId}` ? title : key),
});
const html = renderToStaticMarkup(page);
expect(html).toContain(`<h1>${title}</h1>`);
},
);
@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
@@ -9,6 +10,7 @@ import {
} from "./content-page-frame";
export function ContentEditorialPage({
title,
context,
result,
routeId,
@@ -17,7 +19,7 @@ export function ContentEditorialPage({
const canPages = context.has(PERMS.PAGES_EDIT);
return (
<ContentPageFrame
title="Editorial content"
title={title ?? "Editorial content"}
description="Manage articles, navigation, tags, and writable boxes."
result={result}
forms={
@@ -96,6 +98,7 @@ export function ContentEditorialPage({
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Editorial content");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.editorial.articles",
params: input.match.params,
@@ -103,6 +106,7 @@ export async function renderContentEditorialPage(input: HousekeepingPageInput) {
});
return (
<ContentEditorialPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import {
@@ -12,6 +13,7 @@ import {
} from "./content-page-frame";
export function ContentEngagementPage({
title,
context,
result,
routeId,
@@ -131,7 +133,7 @@ export function ContentEngagementPage({
];
return (
<ContentPageFrame
title="Engagement"
title={title ?? "Engagement"}
description="Manage events, polls, and community prefixes."
result={result}
forms={
@@ -414,6 +416,7 @@ export async function renderContentEngagementPage(
input: HousekeepingPageInput,
) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Engagement");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.engagement.events",
params: input.match.params,
@@ -421,6 +424,7 @@ export async function renderContentEngagementPage(
});
return (
<ContentEngagementPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
@@ -9,6 +10,7 @@ import {
} from "./content-page-frame";
export function ContentHelpPage({
title,
context,
result,
routeId,
@@ -72,7 +74,7 @@ export function ContentHelpPage({
) : null;
return (
<ContentPageFrame
title="Help content"
title={title ?? "Help content"}
description="Manage help questions and email templates."
result={result}
forms={forms}
@@ -82,6 +84,7 @@ export function ContentHelpPage({
export async function renderContentHelpPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Help content");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.help.questions",
params: input.match.params,
@@ -89,6 +92,7 @@ export async function renderContentHelpPage(input: HousekeepingPageInput) {
});
return (
<ContentHelpPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
@@ -9,6 +10,7 @@ import {
} from "./content-page-frame";
export function ContentLocalizationPage({
title,
context,
result,
routeId,
@@ -91,7 +93,7 @@ export function ContentLocalizationPage({
) : null;
return (
<ContentPageFrame
title="Localization"
title={title ?? "Localization"}
description="Manage CMS, client, and emulator translation stores."
result={result}
forms={forms}
@@ -103,6 +105,7 @@ export async function renderContentLocalizationPage(
input: HousekeepingPageInput,
) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Localization");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.localization.overview",
params: input.match.params,
@@ -110,6 +113,7 @@ export async function renderContentLocalizationPage(
});
return (
<ContentLocalizationPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -1,4 +1,5 @@
import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
@@ -9,6 +10,7 @@ import {
} from "./content-page-frame";
export function ContentMediaPage({
title,
context,
result,
routeId,
@@ -17,7 +19,7 @@ export function ContentMediaPage({
const canBanners = context.has(PERMS.BANNERS_EDIT);
return (
<ContentPageFrame
title="Media"
title={title ?? "Media"}
description="Manage photos, uploaded media, banners, and advertisements."
result={result}
forms={
@@ -112,6 +114,7 @@ export function ContentMediaPage({
export async function renderContentMediaPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Media");
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.media.photos",
params: input.match.params,
@@ -119,6 +122,7 @@ export async function renderContentMediaPage(input: HousekeepingPageInput) {
});
return (
<ContentMediaPage
title={title}
context={input.context}
result={result}
routeId={routeId}
@@ -253,4 +253,70 @@ describe("housekeeping localization contract", () => {
"Stiamo preparando gli strumenti di housekeeping disponibili.",
);
});
it("keeps route-specific Content copy in the primary operator locales", () => {
expect(en.pages.housekeeping.routes.content.editorial).toEqual({
"article-create": "New article",
"article-detail": "Edit article",
articles: "Articles",
tags: "Tags",
"writeable-boxes": "Writable boxes",
});
expect(nl.pages.housekeeping.routes.content.editorial).toEqual({
"article-create": "Nieuw artikel",
"article-detail": "Artikel bewerken",
articles: "Artikelen",
tags: "Tags",
"writeable-boxes": "Bewerkbare tekstblokken",
});
expect(itMessages.pages.housekeeping.routes.content.editorial).toEqual({
"article-create": "Nuovo articolo",
"article-detail": "Modifica articolo",
articles: "Articoli",
tags: "Tag",
"writeable-boxes": "Riquadri modificabili",
});
});
it("uses concrete Content workflow names across the primary operator locales", () => {
const expectations = [
[
en,
"New advertisement",
"New event",
"New help article",
"Theme builder",
"CMS translations",
],
[
nl,
"Nieuwe advertentie",
"Nieuw evenement",
"Nieuw helpartikel",
"Thema-bouwer",
"CMS-vertalingen",
],
[
itMessages,
"Nuova pubblicità",
"Nuovo evento",
"Nuovo articolo di assistenza",
"Editor tema",
"Traduzioni CMS",
],
] as const;
for (const [
messages,
media,
engagement,
help,
brand,
localization,
] of expectations) {
const routes = messages.pages.housekeeping.routes.content;
expect(routes.media["ad-create"]).toBe(media);
expect(routes.engagement["event-create"]).toBe(engagement);
expect(routes.help["question-create"]).toBe(help);
expect(routes.brand["theme-builder"]).toBe(brand);
expect(routes.localization.cms).toBe(localization);
}
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { HousekeepingRouteRenderInput } from "../routing/route-handler";
import {
resolveHousekeepingMessage,
resolveHousekeepingRouteTitle,
} from "./route-copy";
function input(
translate?: (key: string) => string,
): HousekeepingRouteRenderInput {
return {
context: {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
},
match: {
routeId: "content.editorial.article-create",
domain: "content",
params: {},
canonicalHref: "/ase-next/content/editorial/articles/new",
},
...(translate ? { translate } : {}),
};
}
describe("Housekeeping route copy", () => {
it("resolves the localized title from the concrete route ID", () => {
expect(
resolveHousekeepingRouteTitle(
input((key) =>
key === "routes.content.editorial.article-create"
? "New article"
: key,
),
"Editorial content",
),
).toBe("New article");
});
it("uses the explicit fallback when translation is absent or unresolved", () => {
expect(resolveHousekeepingRouteTitle(input(), "Editorial content")).toBe(
"Editorial content",
);
expect(
resolveHousekeepingMessage(
input((key) => key),
"routes.content.editorial.article-create",
"Editorial content",
),
).toBe("Editorial content");
});
});
@@ -0,0 +1,21 @@
import type { HousekeepingRouteRenderInput } from "../routing/route-handler";
export function resolveHousekeepingMessage(
input: Pick<HousekeepingRouteRenderInput, "translate">,
key: string,
fallback: string,
): string {
const value = input.translate?.(key);
return value && value !== key ? value : fallback;
}
export function resolveHousekeepingRouteTitle(
input: Pick<HousekeepingRouteRenderInput, "match" | "translate">,
fallback: string,
): string {
return resolveHousekeepingMessage(
input,
`routes.${input.match.routeId}`,
fallback,
);
}
+8 -8
View File
@@ -3315,18 +3315,18 @@
"content": {
"brand": {
"favicon": "Favicon",
"theme-builder": "Theme Builder",
"theme-builder": "Theme builder",
"theme": "Theme"
},
"editorial": {
"article-create": "Editorial article Create",
"article-detail": "Editorial article Detail",
"article-create": "New article",
"article-detail": "Edit article",
"articles": "Articles",
"tags": "Tags",
"writeable-boxes": "Editorial writeable Boxes"
"writeable-boxes": "Writable boxes"
},
"engagement": {
"event-create": "Engagement event Create",
"event-create": "New event",
"event-detail": "Engagement event Detail",
"event-types": "Engagement event Types",
"events": "Events",
@@ -3337,18 +3337,18 @@
},
"help": {
"email-templates": "Help center",
"question-create": "Help center",
"question-create": "New help article",
"question-detail": "Help center",
"questions": "Help center"
},
"localization": {
"client": "Translations",
"cms": "Translations",
"cms": "CMS translations",
"emulator": "Translations",
"overview": "Translations"
},
"media": {
"ad-create": "Media",
"ad-create": "New advertisement",
"ad-detail": "Media",
"ads": "Advertisements",
"banners": "Banners",
+8 -8
View File
@@ -3315,18 +3315,18 @@
"content": {
"brand": {
"favicon": "Favicon",
"theme-builder": "Costruttore temi",
"theme-builder": "Editor tema",
"theme": "Tema"
},
"editorial": {
"article-create": "Editorial article Create",
"article-detail": "Editorial article Detail",
"article-create": "Nuovo articolo",
"article-detail": "Modifica articolo",
"articles": "Articoli",
"tags": "Tag",
"writeable-boxes": "Editorial writeable Boxes"
"writeable-boxes": "Riquadri modificabili"
},
"engagement": {
"event-create": "Engagement event Create",
"event-create": "Nuovo evento",
"event-detail": "Engagement event Detail",
"event-types": "Engagement event Types",
"events": "Eventi",
@@ -3337,18 +3337,18 @@
},
"help": {
"email-templates": "Centro assistenza",
"question-create": "Centro assistenza",
"question-create": "Nuovo articolo di assistenza",
"question-detail": "Centro assistenza",
"questions": "Centro assistenza"
},
"localization": {
"client": "Traduzioni",
"cms": "Traduzioni",
"cms": "Traduzioni CMS",
"emulator": "Traduzioni",
"overview": "Traduzioni"
},
"media": {
"ad-create": "Media",
"ad-create": "Nuova pubblicità",
"ad-detail": "Media",
"ads": "Pubblicità",
"banners": "Banner",
+8 -8
View File
@@ -3220,18 +3220,18 @@
"content": {
"brand": {
"favicon": "Favicon",
"theme-builder": "Themabouwer",
"theme-builder": "Thema-bouwer",
"theme": "Thema"
},
"editorial": {
"article-create": "Editorial article Create",
"article-detail": "Editorial article Detail",
"article-create": "Nieuw artikel",
"article-detail": "Artikel bewerken",
"articles": "Artikelen",
"tags": "Tags",
"writeable-boxes": "Editorial writeable Boxes"
"writeable-boxes": "Bewerkbare tekstblokken"
},
"engagement": {
"event-create": "Engagement event Create",
"event-create": "Nieuw evenement",
"event-detail": "Engagement event Detail",
"event-types": "Engagement event Types",
"events": "Events",
@@ -3242,18 +3242,18 @@
},
"help": {
"email-templates": "Helpcentrum",
"question-create": "Helpcentrum",
"question-create": "Nieuw helpartikel",
"question-detail": "Helpcentrum",
"questions": "Helpcentrum"
},
"localization": {
"client": "Vertalingen",
"cms": "Vertalingen",
"cms": "CMS-vertalingen",
"emulator": "Vertalingen",
"overview": "Vertalingen"
},
"media": {
"ad-create": "Media",
"ad-create": "Nieuwe advertentie",
"ad-detail": "Media",
"ads": "Advertenties",
"banners": "Banners",