feat(task-023): bind workflow templates into demo flow

This commit is contained in:
2026-05-22 19:38:29 +03:00
parent baff9211e8
commit 5f3e543ea2
24 changed files with 2671 additions and 148 deletions
+6
View File
@@ -161,6 +161,12 @@ main {
font-size: 12px;
}
.fieldHint {
margin: 0;
color: #475467;
font-size: 12px;
}
.formSuccess {
margin: 0;
color: #027a48;
@@ -149,7 +149,7 @@ export function AdminWorkflowBuilder({
async function refreshWorkflowList(selectedWorkflowId?: string) {
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
setWorkflows(listResponse.workflows);
setWorkflows(listResponse.workflows ?? []);
if (selectedWorkflowId) {
await loadWorkflow(selectedWorkflowId, false);
}
@@ -39,6 +39,24 @@ export type JobDetailRow = {
stderr: string;
};
export type WorkflowTemplateStageRow = {
id: string;
stableKey: string;
displayName: string;
position: number;
ownerRole: string;
runnerProfileKey: string;
requiresHumanApproval: boolean;
};
export type WorkflowTemplateSnapshotView = {
id: string;
name: string;
slug: string;
version: number;
stages: WorkflowTemplateStageRow[];
};
export type DetailSummary = {
articleId: string;
status: string;
@@ -46,6 +64,7 @@ export type DetailSummary = {
publishingValidationLabel: string;
briefDescription: string;
targetSite: string;
workflowTemplate: WorkflowTemplateSnapshotView | null;
updatedAt: string;
viewerRole: Role;
viewerEmail: string;
@@ -101,6 +120,7 @@ export function buildDetailSummary(
publishingValidationLabel: resolvePublishingValidationLabel(detail),
briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown",
workflowTemplate: buildWorkflowTemplateSnapshotView(detail),
updatedAt: detail.article.updated_at,
viewerRole,
viewerEmail,
@@ -146,6 +166,33 @@ export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobCo
};
}
export function buildWorkflowTemplateSnapshotView(
detail: ArticleDetailResponse,
): WorkflowTemplateSnapshotView | null {
const snapshot = detail.workflow_template_snapshot;
if (!snapshot) {
return null;
}
return {
id: snapshot.id,
name: snapshot.name,
slug: snapshot.slug,
version: snapshot.version,
stages: (snapshot.stage_summary ?? [])
.map((stage) => ({
id: stage.id,
stableKey: stage.stable_key,
displayName: stage.display_name,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiresHumanApproval: Boolean(stage.requires_human_approval),
}))
.sort((left, right) => left.position - right.position),
};
}
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
const manifest = detail.publish_commit?.content_bundle_manifest;
if (manifest && typeof manifest === "object") {
@@ -18,6 +18,7 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline;
const jobDetails = summary.jobDetails;
const workflowTemplate = summary.workflowTemplate;
const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) {
@@ -104,6 +105,49 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
</li>
</ul>
<h3>Workflow template</h3>
{workflowTemplate ? (
<>
<dl className="detailGrid">
<div>
<dt>Name</dt>
<dd>{workflowTemplate.name}</dd>
</div>
<div>
<dt>Slug</dt>
<dd>{workflowTemplate.slug}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{workflowTemplate.version}</dd>
</div>
<div>
<dt>Stages</dt>
<dd>{workflowTemplate.stages.length}</dd>
</div>
</dl>
<ol className="timeline">
{workflowTemplate.stages.map((stage) => (
<li key={stage.id}>
<div className="timelineMeta">
<strong>{stage.position}. {stage.displayName}</strong>
<span className="timelineSource">{stage.ownerRole}</span>
<span>{stage.runnerProfileKey}</span>
</div>
<div>
{stage.stableKey}
{stage.requiresHumanApproval ? (
<span> - approval required</span>
) : null}
</div>
</li>
))}
</ol>
</>
) : (
<p>No workflow template snapshot.</p>
)}
<h3>Workflow timeline</h3>
<ul className="timeline">
{timeline.map((event) => (
@@ -1,13 +1,25 @@
import type { TargetSiteConfig } from "@pipeline/shared";
import type {
ArticleCreateRequest,
TargetSiteConfig,
WorkflowTemplateSummary,
} from "@pipeline/shared";
export type NewArticleDraft = {
targetSiteId: string;
workflowTemplateId: string;
briefDescription: string;
workingTitle: string;
contentType: string;
primaryKeyword: string;
};
export type WorkflowTemplateOption = {
id: string;
label: string;
version: number;
stageCount: number;
};
export type FieldErrors = Record<string, string>;
export type NewArticleFormState = {
@@ -16,9 +28,14 @@ export type NewArticleFormState = {
formError?: string;
};
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft {
export function initialDraft(
sites: readonly TargetSiteConfig[],
workflows: readonly WorkflowTemplateSummary[] = [],
): NewArticleDraft {
const workflowOptions = buildWorkflowTemplateOptions(workflows);
return {
targetSiteId: sites.length > 0 ? sites[0].id : "",
workflowTemplateId: workflowOptions.length > 0 ? workflowOptions[0].id : "",
briefDescription: "",
workingTitle: "",
contentType: "longform_guide",
@@ -26,6 +43,33 @@ export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraf
};
}
export function buildWorkflowTemplateOptions(
workflows: readonly WorkflowTemplateSummary[],
): WorkflowTemplateOption[] {
return workflows
.filter((workflow) => workflow.status === "ACTIVE")
.map((workflow) => ({
id: workflow.id,
label: `${workflow.name} v${workflow.version}`,
version: workflow.version,
stageCount: workflow.stages?.length ?? 0,
}));
}
export function buildCreateArticlePayload(
values: NewArticleDraft,
): ArticleCreateRequest {
return {
target_site_id: values.targetSiteId,
workflow_template_id: values.workflowTemplateId || null,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
};
}
export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
const errors: FieldErrors = {};
@@ -3,10 +3,12 @@
import { useMemo, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { TargetSiteConfig } from "@pipeline/shared";
import type { TargetSiteConfig, WorkflowTemplateSummary } from "@pipeline/shared";
import { ApiError, createArticle } from "@/shared/pipeline-api";
import {
buildCreateArticlePayload,
buildWorkflowTemplateOptions,
initialDraft,
validateArticleDraft,
type FieldErrors,
@@ -15,14 +17,24 @@ import {
type NewArticleFormProps = {
sites: readonly TargetSiteConfig[];
workflowTemplates: readonly WorkflowTemplateSummary[];
};
export function NewArticleForm({ sites }: NewArticleFormProps) {
export function NewArticleForm({ sites, workflowTemplates }: NewArticleFormProps) {
const router = useRouter();
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites));
const workflowOptions = useMemo(
() => buildWorkflowTemplateOptions(workflowTemplates),
[workflowTemplates],
);
const [values, setValues] = useState<NewArticleDraft>(() =>
initialDraft(sites, workflowTemplates),
);
const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const selectedWorkflow = workflowOptions.find(
(workflow) => workflow.id === values.workflowTemplateId,
);
const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0,
@@ -41,14 +53,7 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
setFormError("");
setSubmitting(true);
try {
const response = await createArticle({
target_site_id: values.targetSiteId,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
});
const response = await createArticle(buildCreateArticlePayload(values));
router.push(`/articles/${response.article.id}`);
} catch (error) {
if (error instanceof ApiError) {
@@ -84,6 +89,26 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
</select>
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
<label htmlFor="workflowTemplateId">Workflow template</label>
<select
id="workflowTemplateId"
value={values.workflowTemplateId}
onChange={(event) => updateField("workflowTemplateId", event.target.value)}
disabled={workflowOptions.length === 0}
>
<option value="">No active workflow template</option>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.label} - {workflow.stageCount} stages
</option>
))}
</select>
{selectedWorkflow ? (
<p className="fieldHint">
{selectedWorkflow.label} - {selectedWorkflow.stageCount} stages
</p>
) : null}
<label htmlFor="briefDescription">Brief description</label>
<textarea
id="briefDescription"
@@ -56,7 +56,7 @@ export default async function AdminWorkflowsPage({
</header>
<RoleNavigation role="ADMIN" />
<AdminWorkflowBuilder
initialWorkflows={workflowListResponse.workflows}
initialWorkflows={workflowListResponse.workflows ?? []}
initialWorkflow={selectedWorkflow}
initialAuditEvents={auditEvents}
/>
+14 -3
View File
@@ -3,11 +3,19 @@ import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
import { NewArticleForm } from "@/features/article-intake/ui";
import { fetchTargetSites } from "@/shared/pipeline-api";
import {
DEMO_EDITOR_EMAIL,
fetchTargetSites,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
export default async function NewArticlePage() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
const [sites, workflowResponse] = await Promise.all([
fetchTargetSites(),
fetchWorkflowTemplates(DEMO_EDITOR_EMAIL),
]);
const targetSites = sites.map((site) => site.site);
const workflowTemplates = workflowResponse.workflows ?? [];
return (
<main>
@@ -20,7 +28,10 @@ export default async function NewArticlePage() {
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<NewArticleForm sites={targetSites} />
<NewArticleForm
sites={targetSites}
workflowTemplates={workflowTemplates}
/>
</section>
</main>
);
@@ -30,6 +30,32 @@ const detail = {
updated_at: "2026-05-21T00:00:00Z",
},
target_site: { name: "Demo Site" },
workflow_template_snapshot: {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
version: 2,
stage_summary: [
{
id: "stage-2",
stable_key: "draft",
display_name: "Draft assembly",
position: 2,
owner_role: "EDITOR",
runner_profile_key: "draft-agent",
requires_human_approval: false,
},
{
id: "stage-1",
stable_key: "intake",
display_name: "Intake",
position: 1,
owner_role: "EDITOR",
runner_profile_key: "intake-agent",
requires_human_approval: true,
},
],
},
timeline: [
{
id: "2",
@@ -131,5 +157,11 @@ const summary = buildDetailSummary(detail, {
assert.equal(summary.viewerRole, "EDITOR");
assert.equal(summary.viewerEmail, "editor@example.com");
assert.equal(summary.targetSite, "Demo Site");
assert.equal(summary.workflowTemplate.name, "Demo workflow");
assert.equal(summary.workflowTemplate.version, 2);
assert.deepEqual(
summary.workflowTemplate.stages.map((stage) => stage.stableKey),
["intake", "draft"],
);
assert.equal(summary.timeline.length, 2);
assert.equal(summary.jobDetails.length, 1);
@@ -23,9 +23,29 @@ new Function("exports", compiled.outputText)(moduleExports);
const validateArticleDraft = moduleExports.validateArticleDraft;
const initialDraft = moduleExports.initialDraft;
const buildCreateArticlePayload = moduleExports.buildCreateArticlePayload;
const buildWorkflowTemplateOptions = moduleExports.buildWorkflowTemplateOptions;
const activeWorkflow = {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
description: "Demo workflow",
status: "ACTIVE",
version: 2,
created_by: "admin",
updated_by: "admin",
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
stages: [
{ id: "stage-1" },
{ id: "stage-2" },
],
};
const draft = {
targetSiteId: "",
workflowTemplateId: "",
briefDescription: "Keep this text intact",
workingTitle: "",
contentType: "longform_guide",
@@ -43,3 +63,27 @@ assert.equal(
const fallbackDraft = initialDraft([]);
assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved");
const workflowDraft = initialDraft(
[{ id: "site-1" }],
[activeWorkflow, { ...activeWorkflow, id: "workflow-archived", status: "ARCHIVED" }],
);
assert.equal(workflowDraft.workflowTemplateId, "workflow-1");
assert.deepEqual(buildWorkflowTemplateOptions([activeWorkflow]), [
{
id: "workflow-1",
label: "Demo workflow v2",
version: 2,
stageCount: 2,
},
]);
assert.equal(
buildCreateArticlePayload({
...workflowDraft,
briefDescription: "Brief",
primaryKeyword: "workflow",
}).workflow_template_id,
"workflow-1",
);