feat(housekeeping): complete article workflow
This commit is contained in:
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("<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(
|
||||
<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",
|
||||
|
||||
Reference in new issue
Block a user