From fa33b4498e1341559aa394d1ff82c48a94573bd5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 31 Aug 2026 21:42:57 +0200 Subject: [PATCH] feat(housekeeping): complete article workflow --- .../content/pages/article-workflow.test.tsx | 156 +++++++++ .../content/pages/article-workflow.tsx | 304 ++++++++++++++++++ .../content/pages/content-command-form.tsx | 5 + .../content/pages/content-page-frame.tsx | 1 + .../domains/content/pages/editorial.tsx | 21 +- .../queries/content-queries-production.ts | 12 +- .../content/queries/content-queries.test.ts | 61 ++++ .../content/queries/content-queries.ts | 30 +- .../foundation-source-contract.test.ts | 8 + 9 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 src/features/housekeeping/domains/content/pages/article-workflow.test.tsx create mode 100644 src/features/housekeeping/domains/content/pages/article-workflow.tsx diff --git a/src/features/housekeeping/domains/content/pages/article-workflow.test.tsx b/src/features/housekeeping/domains/content/pages/article-workflow.test.tsx new file mode 100644 index 00000000..df959c8a --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/article-workflow.test.tsx @@ -0,0 +1,156 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { ok } from "../../../foundation/contracts"; +import { ContentEditorialPage } from "./editorial"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +const allPermissions = context([PERMS.NEWS_VIEW, PERMS.NEWS_EDIT]); + +describe("Content article workflow", () => { + it("renders searchable article results with a truthful total and create action", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + const html = renderToStaticMarkup( + , + ); + + for (const field of ["title", "slug", "image", "shortStory", "fullStory"]) { + expect(html).toContain(`name="${field}"`); + } + expect(html).toContain( + 'data-housekeeping-command="content.editorial.article.change"', + ); + expect(html).toContain("Publish article"); + }); + + it("prefills the full article editor and requires a reason for deletion", () => { + const html = renderToStaticMarkup( + Complete launch story

", + }, + }, + ], + total: 1, + partialDependencies: [], + }, + "article-detail", + )} + />, + ); + + expect(html).toContain('value="Launch day"'); + expect(html).toContain('value="launch-day"'); + expect(html).toContain('value="/media/launch.webp"'); + expect(html).toContain("Short launch summary"); + expect(html).toContain("<p>Complete launch story</p>"); + expect(html).toContain("Save changes"); + expect(html).toContain("Delete article"); + expect(html).toContain('name="reason"'); + }); + + it("keeps mutation controls hidden from read-only operators", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Launch day"); + expect(html).not.toContain("content.editorial.article.change"); + }); +}); diff --git a/src/features/housekeeping/domains/content/pages/article-workflow.tsx b/src/features/housekeeping/domains/content/pages/article-workflow.tsx new file mode 100644 index 00000000..ac196fdb --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/article-workflow.tsx @@ -0,0 +1,304 @@ +import { PERMS } from "@/lib/permission-slugs"; +import type { + HousekeepingCapabilityContext, + HousekeepingResult, +} from "../../../foundation/contracts"; +import { + type ContentArticlePrivatePayload, + type ContentQueryData, + type ContentQueryItem, + isContentArticlePrivatePayload, +} from "../queries/content-queries"; +import { + type ContentCommandField, + ContentCommandForm, +} from "./content-command-form"; + +type ArticleRouteId = + | "content.editorial.articles" + | "content.editorial.article-create" + | "content.editorial.article-detail"; + +interface ContentArticleWorkflowProps { + readonly title: string; + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly routeId: ArticleRouteId; + readonly search?: string; +} + +function articleFields( + item?: ContentQueryItem, + payload?: ContentArticlePrivatePayload, +): readonly ContentCommandField[] { + return [ + { + name: "title", + label: "Title", + type: "text", + required: true, + maxLength: 255, + defaultValue: item?.title, + }, + { + name: "slug", + label: "Slug", + type: "text", + maxLength: 255, + defaultValue: payload?.slug, + }, + { + name: "image", + label: "Cover image URL", + type: "text", + maxLength: 255, + defaultValue: payload?.image, + }, + { + name: "shortStory", + label: "Summary", + type: "textarea", + maxLength: 255, + defaultValue: payload?.shortStory, + }, + { + name: "fullStory", + label: "Article body", + type: "textarea", + maxLength: 100_000, + defaultValue: payload?.fullStory, + }, + ]; +} + +function ArticleState({ + title, + state, + message, +}: { + readonly title: string; + readonly state: "loading" | "forbidden" | "error"; + readonly message: string; +}) { + return ( +
+

{title}

+

{message}

+
+ ); +} + +function ArticleList({ + title, + context, + result, + search, +}: Omit & { + readonly result: Extract, { ok: true }>; +}) { + const canEdit = context.has(PERMS.NEWS_EDIT); + const state = result.data.items.length === 0 ? "empty" : "ready"; + return ( +
+
+
+

{title}

+

Create, find, and maintain published news articles.

+
+ {canEdit ? ( + + New article + + ) : null} +
+ + +
+ + + + {search ? ( + Clear + ) : null} +
+
+ +

+ Showing {result.data.items.length} of {result.data.total} articles +

+ + {result.data.items.length === 0 ? ( +

No articles match the current search.

+ ) : ( +
    + {result.data.items.map((item) => ( +
  • +

    + {item.href ? {item.title} : item.title} +

    + {item.description ? ( +

    + /{item.description} +

    + ) : null} +
    + {item.status ? {item.status} : null} + {item.updatedAt ? ( + + ) : null} +
    +
  • + ))} +
+ )} +
+ ); +} + +function ArticleCreate({ + title, + context, +}: Pick) { + if (!context.has(PERMS.NEWS_EDIT)) { + return ( + + ); + } + return ( +
+
+ Back to articles +

{title}

+

Prepare the headline, public URL, cover image, summary, and body.

+
+ +
+ ); +} + +function ArticleDetail({ + title, + context, + result, +}: Omit & { + readonly result: Extract, { ok: true }>; +}) { + const item = result.data.items[0]; + if (!item) { + return ( +
+

{title}

+

The requested article was not found.

+ Back to articles +
+ ); + } + if (!isContentArticlePrivatePayload(item.privatePayload)) { + return ( + + ); + } + + const payload = item.privatePayload; + const canEdit = context.has(PERMS.NEWS_EDIT); + return ( +
+
+ Back to articles +

{title}

+

+ {item.title} · /{payload.slug} +

+
+ + {canEdit ? ( + <> + +
+

Danger zone

+

+ Deleting an article also removes its comments and reactions. +

+ +
+ + ) : ( +
+

{payload.shortStory}

+
{payload.fullStory}
+
+ )} +
+ ); +} + +export function ContentArticleWorkflow(props: ContentArticleWorkflowProps) { + if (!props.result) { + return ( + + ); + } + if (!props.result.ok) { + return ( + + ); + } + if (props.routeId === "content.editorial.articles") { + return ; + } + if (props.routeId === "content.editorial.article-create") { + return ; + } + return ; +} diff --git a/src/features/housekeeping/domains/content/pages/content-command-form.tsx b/src/features/housekeeping/domains/content/pages/content-command-form.tsx index e511efaa..2e62b81c 100644 --- a/src/features/housekeeping/domains/content/pages/content-command-form.tsx +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -191,6 +191,11 @@ export function ContentCommandForm({