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 (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
+
+ Showing {result.data.items.length} of {result.data.total} articles
+
+
+ {result.data.items.length === 0 ? (
+ No articles match the current search.
+ ) : (
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+ }
+ if (!isContentArticlePrivatePayload(item.privatePayload)) {
+ return (
+
+ );
+ }
+
+ const payload = item.privatePayload;
+ const canEdit = context.has(PERMS.NEWS_EDIT);
+ return (
+
+
+
+ {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({