feat(housekeeping): complete article workflow
CI / check (pull_request) Failing after 11s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-31 21:42:57 +02:00
1 parent fc050bd3f8
commit fa33b4498e
9 files changed
+595 -3

No files matched your search

@@ -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(
<ContentEditorialPage
title="Articles"
context={allPermissions}
routeId="content.editorial.articles"
search="launch"
result={ok(
{
kind: "editorial",
items: [
{
id: "7",
title: "Launch day",
description: "launch-day",
status: "published",
href: "/ase-next/content/editorial/articles/7",
},
],
total: 42,
partialDependencies: [],
},
"articles-list",
)}
/>,
);
expect(html).toContain("<search");
expect(html).toContain('name="search"');
expect(html).toContain('value="launch"');
expect(html).toContain("Showing 1 of 42 articles");
expect(html).toContain('href="/ase-next/content/editorial/articles/new"');
});
it("renders every persisted article field on creation", () => {
const html = renderToStaticMarkup(
<ContentEditorialPage
title="New article"
context={allPermissions}
routeId="content.editorial.article-create"
result={ok(
{
kind: "editorial",
items: [],
total: 0,
partialDependencies: [],
},
"article-create",
)}
/>,
);
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(
<ContentEditorialPage
title="Edit article"
context={allPermissions}
routeId="content.editorial.article-detail"
result={ok(
{
kind: "editorial",
items: [
{
id: "7",
title: "Launch day",
description: "launch-day",
status: "published",
privatePayload: {
slug: "launch-day",
image: "/media/launch.webp",
shortStory: "Short launch summary",
fullStory: "<p>Complete launch story</p>",
},
},
],
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("&lt;p&gt;Complete launch story&lt;/p&gt;");
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(
<ContentEditorialPage
title="Edit article"
context={context([PERMS.NEWS_VIEW])}
routeId="content.editorial.article-detail"
result={ok(
{
kind: "editorial",
items: [
{
id: "7",
title: "Launch day",
privatePayload: {
slug: "launch-day",
image: "",
shortStory: "Summary",
fullStory: "Story",
},
},
],
total: 1,
partialDependencies: [],
},
"article-read-only",
)}
/>,
);
expect(html).toContain("Launch day");
expect(html).not.toContain("content.editorial.article.change");
});
});
@@ -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<ContentQueryData>;
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 (
<section
data-housekeeping-state={state}
role={state === "loading" ? undefined : "alert"}
className="space-y-2"
>
<h1>{title}</h1>
<p>{message}</p>
</section>
);
}
function ArticleList({
title,
context,
result,
search,
}: Omit<ContentArticleWorkflowProps, "routeId" | "result"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
const canEdit = context.has(PERMS.NEWS_EDIT);
const state = result.data.items.length === 0 ? "empty" : "ready";
return (
<section data-housekeeping-state={state} className="space-y-5">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1>{title}</h1>
<p>Create, find, and maintain published news articles.</p>
</div>
{canEdit ? (
<a
href="/ase-next/content/editorial/articles/new"
className="rounded bg-[var(--admin-accent)] px-3 py-2 text-sm font-semibold text-[var(--admin-accent-foreground)]"
>
New article
</a>
) : null}
</header>
<search>
<form method="get" className="flex flex-wrap gap-2">
<label htmlFor="article-search" className="sr-only">
Search articles
</label>
<input
id="article-search"
name="search"
type="search"
defaultValue={search}
placeholder="Search by title or slug"
maxLength={128}
className="min-w-64 flex-1 rounded border border-[var(--admin-border)] px-3 py-2"
/>
<button type="submit">Search</button>
{search ? (
<a href="/ase-next/content/editorial/articles">Clear</a>
) : null}
</form>
</search>
<p className="text-sm text-[var(--admin-text-muted)]">
Showing {result.data.items.length} of {result.data.total} articles
</p>
{result.data.items.length === 0 ? (
<p>No articles match the current search.</p>
) : (
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{result.data.items.map((item) => (
<li
key={item.id}
className="rounded border border-[var(--admin-border)] p-4"
>
<h2 className="font-semibold">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
</h2>
{item.description ? (
<p className="text-sm text-[var(--admin-text-muted)]">
/{item.description}
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{item.status ? <span>{item.status}</span> : null}
{item.updatedAt ? (
<time dateTime={item.updatedAt}>{item.updatedAt}</time>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
);
}
function ArticleCreate({
title,
context,
}: Pick<ContentArticleWorkflowProps, "title" | "context">) {
if (!context.has(PERMS.NEWS_EDIT)) {
return (
<ArticleState
title={title}
state="forbidden"
message="You do not have permission to create articles."
/>
);
}
return (
<section data-housekeeping-state="ready" className="space-y-5">
<header>
<a href="/ase-next/content/editorial/articles">Back to articles</a>
<h1>{title}</h1>
<p>Prepare the headline, public URL, cover image, summary, and body.</p>
</header>
<ContentCommandForm
commandId="content.editorial.article.change"
buttonLabel="Publish article"
input={{ action: "create" }}
fields={articleFields()}
/>
</section>
);
}
function ArticleDetail({
title,
context,
result,
}: Omit<ContentArticleWorkflowProps, "routeId" | "result"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
const item = result.data.items[0];
if (!item) {
return (
<section data-housekeeping-state="empty" className="space-y-2">
<h1>{title}</h1>
<p>The requested article was not found.</p>
<a href="/ase-next/content/editorial/articles">Back to articles</a>
</section>
);
}
if (!isContentArticlePrivatePayload(item.privatePayload)) {
return (
<ArticleState
title={title}
state="error"
message="The article details are unavailable."
/>
);
}
const payload = item.privatePayload;
const canEdit = context.has(PERMS.NEWS_EDIT);
return (
<section data-housekeeping-state="ready" className="space-y-5">
<header>
<a href="/ase-next/content/editorial/articles">Back to articles</a>
<h1>{title}</h1>
<p>
{item.title} · /{payload.slug}
</p>
</header>
{canEdit ? (
<>
<ContentCommandForm
commandId="content.editorial.article.change"
buttonLabel="Save changes"
input={{ action: "update", id: item.id }}
fields={articleFields(item, payload)}
/>
<div className="rounded border border-[var(--admin-error)] p-4">
<h2 className="font-semibold">Danger zone</h2>
<p className="text-sm">
Deleting an article also removes its comments and reactions.
</p>
<ContentCommandForm
commandId="content.editorial.article.change"
buttonLabel="Delete article"
input={{ action: "delete", id: item.id }}
requiresReason
/>
</div>
</>
) : (
<article className="space-y-3">
<p>{payload.shortStory}</p>
<div className="whitespace-pre-wrap">{payload.fullStory}</div>
</article>
)}
</section>
);
}
export function ContentArticleWorkflow(props: ContentArticleWorkflowProps) {
if (!props.result) {
return (
<ArticleState
title={props.title}
state="loading"
message="Loading articles…"
/>
);
}
if (!props.result.ok) {
return (
<ArticleState
title={props.title}
state={props.result.error.code === "FORBIDDEN" ? "forbidden" : "error"}
message={props.result.error.messageKey}
/>
);
}
if (props.routeId === "content.editorial.articles") {
return <ArticleList {...props} result={props.result} />;
}
if (props.routeId === "content.editorial.article-create") {
return <ArticleCreate title={props.title} context={props.context} />;
}
return <ArticleDetail {...props} result={props.result} />;
}
@@ -191,6 +191,11 @@ export function ContentCommandForm({
<textarea
id={`${commandId}-${field.name}`}
name={field.name}
defaultValue={
typeof field.defaultValue === "boolean"
? undefined
: field.defaultValue
}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
@@ -4,6 +4,7 @@ import type { ContentQueryData } from "../queries/content-queries";
export interface ContentPageProps {
readonly title?: string;
readonly search?: string;
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: import("../routes").ContentRouteId | null;
@@ -2,6 +2,7 @@ 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 { ContentArticleWorkflow } from "./article-workflow";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
@@ -14,9 +15,25 @@ export function ContentEditorialPage({
context,
result,
routeId,
search,
}: ContentPageProps) {
const canNews = context.has(PERMS.NEWS_EDIT);
const canPages = context.has(PERMS.PAGES_EDIT);
if (
routeId === "content.editorial.articles" ||
routeId === "content.editorial.article-create" ||
routeId === "content.editorial.article-detail"
) {
return (
<ContentArticleWorkflow
title={title ?? "Articles"}
context={context}
result={result}
routeId={routeId}
search={search}
/>
);
}
return (
<ContentPageFrame
title={title ?? "Editorial content"}
@@ -99,10 +116,11 @@ export function ContentEditorialPage({
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Editorial content");
const list = parseContentListInput(input.searchParams ?? {});
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.editorial.articles",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
list,
});
return (
<ContentEditorialPage
@@ -110,6 +128,7 @@ export async function renderContentEditorialPage(input: HousekeepingPageInput) {
context={input.context}
result={result}
routeId={routeId}
search={list.search}
/>
);
}
@@ -14,6 +14,7 @@ interface QueryDefinition {
readonly statement: string;
readonly href: string;
readonly appendId?: boolean;
readonly privatePayload?: (row: RawRow) => unknown;
}
export const CONTENT_QUERY_DEFINITIONS = {
@@ -25,9 +26,15 @@ export const CONTENT_QUERY_DEFINITIONS = {
},
"content.editorial.article-detail": {
statement:
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
"SELECT id, title, slug AS description, slug, image, short_story, full_story, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
href: "/ase-next/content/editorial/articles/",
appendId: true,
privatePayload: (row) => ({
slug: value(row.slug),
image: value(row.image),
shortStory: value(row.short_story),
fullStory: value(row.full_story),
}),
},
"content.editorial.tags": {
statement:
@@ -188,6 +195,9 @@ function mapRows(
status: value(row.status) || undefined,
updatedAt: updatedAt(row.updated_at),
href: definition.appendId ? definition.href + id : definition.href,
...(definition.privatePayload
? { privatePayload: definition.privatePayload(row) }
: {}),
};
});
}
@@ -118,6 +118,41 @@ describe("Content query", () => {
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
});
it("loads the editable payload only for an individual article", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[
{
id: 7,
title: "Launch day",
description: "launch-day",
status: "published",
updated_at: null,
slug: "launch-day",
image: "/media/launch.webp",
short_story: "Short launch summary",
full_story: "<p>Complete launch story</p>",
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.editorial.article-detail",
params: { id: "7" },
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result.items[0]?.privatePayload).toEqual({
slug: "launch-day",
image: "/media/launch.webp",
shortStory: "Short launch summary",
fullStory: "<p>Complete launch story</p>",
});
expect(
CONTENT_QUERY_DEFINITIONS["content.editorial.articles"].statement,
).not.toMatch(/short_story|full_story/iu);
});
it("fails closed before production adapters are invoked", async () => {
const load = vi.fn(async () => ready);
const query = createContentQuery({ load });
@@ -162,6 +197,32 @@ describe("Content query", () => {
});
});
it("fails closed when an article detail payload is incomplete", async () => {
const query = createContentQuery({
load: async () => ({
kind: "editorial",
items: [
{
id: "7",
title: "Launch day",
privatePayload: { slug: "launch-day" },
},
],
total: 1,
partialDependencies: [],
}),
});
const result = await query.run(context([PERMS.NEWS_VIEW]), {
routeId: "content.editorial.article-detail",
params: { id: "7" },
});
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("rejects a fake partial result and maps complete adapter failure", async () => {
const malformed = createContentQuery({
load: async () =>
@@ -24,6 +24,26 @@ export interface ContentQueryInput {
readonly list?: ContentQueryListInput;
}
export interface ContentArticlePrivatePayload {
readonly slug: string;
readonly image: string;
readonly shortStory: string;
readonly fullStory: string;
}
export function isContentArticlePrivatePayload(
value: unknown,
): value is ContentArticlePrivatePayload {
if (value === null || typeof value !== "object") return false;
const payload = value as Record<string, unknown>;
return (
typeof payload.slug === "string" &&
typeof payload.image === "string" &&
typeof payload.shortStory === "string" &&
typeof payload.fullStory === "string"
);
}
export interface ContentQueryItem {
readonly id: string;
readonly title: string;
@@ -100,7 +120,7 @@ function isValidData(
) {
return false;
}
return data.items.every(
const validItems = data.items.every(
(item) =>
typeof item.id === "string" &&
item.id.length > 0 &&
@@ -108,6 +128,14 @@ function isValidData(
item.title.length > 0 &&
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
if (!validItems) return false;
if (input.routeId !== "content.editorial.article-detail") return true;
return (
data.items.length <= 1 &&
data.items.every((item) =>
isContentArticlePrivatePayload(item.privatePayload),
)
);
}
export function createContentQuery(
@@ -280,6 +280,13 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/content/widgets",
]),
],
[
"src/features/housekeeping/domains/content/pages/article-workflow.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/brand.tsx",
new Set([
@@ -302,6 +309,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/domains/content/pages/editorial.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/article-workflow",
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",