feat(task-022): add admin workflow builder ui
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
outputFileTracingRoot: repoRoot,
|
||||
turbopack: {
|
||||
root: repoRoot,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test:roles": "node tests/role-navigation.test.mjs",
|
||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
|
||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/admin_workflows.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminWorkflowsPage from "@/pages/admin-workflows";
|
||||
|
||||
export { AdminWorkflowsPage as default };
|
||||
@@ -0,0 +1,363 @@
|
||||
import type {
|
||||
Role,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStagePart,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowStageSummary,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type WorkflowDraft = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type WorkflowStageDraft = {
|
||||
stableKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
position: number;
|
||||
ownerRole: Role;
|
||||
runnerProfileKey: string;
|
||||
requiredInputs: string[];
|
||||
expectedOutputs: string[];
|
||||
acceptanceCriteria: string[];
|
||||
requiresHumanApproval: boolean;
|
||||
retryPolicy: Record<string, unknown>;
|
||||
parts: WorkflowStagePart[];
|
||||
};
|
||||
|
||||
export type WorkflowStageFormPart = {
|
||||
key: string;
|
||||
type: string;
|
||||
title: string;
|
||||
payloadJson: string;
|
||||
acceptanceCriteriaText: string;
|
||||
};
|
||||
|
||||
export type WorkflowStageFormDraft = Omit<WorkflowStageDraft, "parts"> & {
|
||||
requiredInputsText: string;
|
||||
expectedOutputsText: string;
|
||||
acceptanceCriteriaText: string;
|
||||
retryPolicyJson: string;
|
||||
parts: WorkflowStageFormPart[];
|
||||
};
|
||||
|
||||
export type WorkflowStagePositionItem = {
|
||||
id: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type AdminWorkflowRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
stageCount: number;
|
||||
isDraft: boolean;
|
||||
isActive: boolean;
|
||||
isArchived: boolean;
|
||||
};
|
||||
|
||||
export type AdminWorkflowAuditRow = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
createdAt: string;
|
||||
actorUserId: string | null;
|
||||
payload: string;
|
||||
};
|
||||
|
||||
export type StageDraftValidationErrors = Partial<
|
||||
Record<
|
||||
| "stableKey"
|
||||
| "displayName"
|
||||
| "description"
|
||||
| "runnerProfileKey"
|
||||
| "retryPolicyJson"
|
||||
| "parts",
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
export function defaultWorkflowDraft(): WorkflowDraft {
|
||||
return {
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateWorkflowPayload(
|
||||
draft: WorkflowDraft,
|
||||
): WorkflowTemplateCreateRequest {
|
||||
return {
|
||||
name: draft.name,
|
||||
slug: draft.slug,
|
||||
description: draft.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateWorkflowPayload(
|
||||
draft: WorkflowDraft,
|
||||
): WorkflowTemplateUpdateRequest {
|
||||
return {
|
||||
name: draft.name,
|
||||
slug: draft.slug,
|
||||
description: draft.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkflowStagePart(): WorkflowStagePart {
|
||||
return {
|
||||
key: "stage-part",
|
||||
type: "instruction",
|
||||
title: "Stage instruction",
|
||||
payload: {
|
||||
prompt: "",
|
||||
config: {},
|
||||
},
|
||||
acceptance_criteria: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkflowStageDraft(position = 1): WorkflowStageDraft {
|
||||
return {
|
||||
stableKey: "",
|
||||
displayName: "",
|
||||
description: "",
|
||||
position,
|
||||
ownerRole: "EDITOR",
|
||||
runnerProfileKey: "",
|
||||
requiredInputs: [],
|
||||
expectedOutputs: [],
|
||||
acceptanceCriteria: [],
|
||||
requiresHumanApproval: false,
|
||||
retryPolicy: {
|
||||
max_attempts: 1,
|
||||
backoff_seconds: 0,
|
||||
},
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateWorkflowStagePayload(
|
||||
draft: WorkflowStageDraft,
|
||||
): WorkflowStageCreateRequest {
|
||||
return {
|
||||
stable_key: draft.stableKey,
|
||||
display_name: draft.displayName,
|
||||
description: draft.description,
|
||||
position: draft.position,
|
||||
owner_role: draft.ownerRole,
|
||||
runner_profile_key: draft.runnerProfileKey,
|
||||
required_inputs: draft.requiredInputs,
|
||||
expected_outputs: draft.expectedOutputs,
|
||||
acceptance_criteria: draft.acceptanceCriteria,
|
||||
requires_human_approval: draft.requiresHumanApproval,
|
||||
retry_policy: draft.retryPolicy,
|
||||
parts: draft.parts,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateWorkflowStagePayload(
|
||||
draft: WorkflowStageDraft,
|
||||
): WorkflowStageUpdateRequest {
|
||||
return buildCreateWorkflowStagePayload(draft);
|
||||
}
|
||||
|
||||
export function moveWorkflowStage<TStage extends WorkflowStagePositionItem>(
|
||||
stages: readonly TStage[],
|
||||
stageId: string,
|
||||
targetIndex: number,
|
||||
): TStage[] {
|
||||
const currentIndex = stages.findIndex((stage) => stage.id === stageId);
|
||||
if (currentIndex === -1) {
|
||||
return stages.map((stage, index) => ({
|
||||
...stage,
|
||||
position: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
const nextStages = [...stages];
|
||||
const [stage] = nextStages.splice(currentIndex, 1);
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, nextStages.length));
|
||||
nextStages.splice(clampedIndex, 0, stage);
|
||||
|
||||
return nextStages.map((nextStage, index) => ({
|
||||
...nextStage,
|
||||
position: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildReorderWorkflowStagesPayload(
|
||||
stages: readonly WorkflowStagePositionItem[],
|
||||
): WorkflowStageReorderRequest {
|
||||
return {
|
||||
stage_ids: stages.map((stage) => stage.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAdminWorkflowRows(
|
||||
workflows: readonly WorkflowTemplateSummary[],
|
||||
): AdminWorkflowRow[] {
|
||||
return workflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
slug: workflow.slug,
|
||||
status: workflow.status,
|
||||
version: workflow.version,
|
||||
updatedAt: workflow.updated_at,
|
||||
stageCount: workflow.stages?.length ?? 0,
|
||||
isDraft: workflow.status === "DRAFT",
|
||||
isActive: workflow.status === "ACTIVE",
|
||||
isArchived: workflow.status === "ARCHIVED",
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildAdminWorkflowAuditRows(
|
||||
events: readonly WorkflowTemplateAuditEventSummary[],
|
||||
): AdminWorkflowAuditRow[] {
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
eventType: event.event_type,
|
||||
createdAt: event.created_at,
|
||||
actorUserId: event.actor_user_id ?? null,
|
||||
payload: JSON.stringify(event.payload ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function workflowDraftFromSummary(
|
||||
workflow: WorkflowTemplateSummary,
|
||||
): WorkflowDraft {
|
||||
return {
|
||||
name: workflow.name,
|
||||
slug: workflow.slug,
|
||||
description: workflow.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function workflowStageDraftFromSummary(
|
||||
stage: WorkflowStageSummary,
|
||||
): WorkflowStageDraft {
|
||||
return {
|
||||
stableKey: stage.stable_key,
|
||||
displayName: stage.display_name,
|
||||
description: stage.description,
|
||||
position: stage.position,
|
||||
ownerRole: stage.owner_role,
|
||||
runnerProfileKey: stage.runner_profile_key,
|
||||
requiredInputs: stage.required_inputs ?? [],
|
||||
expectedOutputs: stage.expected_outputs ?? [],
|
||||
acceptanceCriteria: stage.acceptance_criteria ?? [],
|
||||
requiresHumanApproval: stage.requires_human_approval ?? false,
|
||||
retryPolicy: stage.retry_policy ?? {},
|
||||
parts: stage.parts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function workflowStageFormDraftFromSummary(
|
||||
stage: WorkflowStageSummary,
|
||||
): WorkflowStageFormDraft {
|
||||
const draft = workflowStageDraftFromSummary(stage);
|
||||
return workflowStageDraftToFormDraft(draft);
|
||||
}
|
||||
|
||||
export function workflowStageDraftToFormDraft(
|
||||
draft: WorkflowStageDraft,
|
||||
): WorkflowStageFormDraft {
|
||||
return {
|
||||
...draft,
|
||||
requiredInputsText: draft.requiredInputs.join("\n"),
|
||||
expectedOutputsText: draft.expectedOutputs.join("\n"),
|
||||
acceptanceCriteriaText: draft.acceptanceCriteria.join("\n"),
|
||||
retryPolicyJson: JSON.stringify(draft.retryPolicy, null, 2),
|
||||
parts: draft.parts.map((part) => ({
|
||||
key: part.key,
|
||||
type: part.type,
|
||||
title: part.title,
|
||||
payloadJson: JSON.stringify(part.payload ?? {}, null, 2),
|
||||
acceptanceCriteriaText: (part.acceptance_criteria ?? []).join("\n"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkflowStageFormDraft(
|
||||
position = 1,
|
||||
): WorkflowStageFormDraft {
|
||||
return workflowStageDraftToFormDraft(defaultWorkflowStageDraft(position));
|
||||
}
|
||||
|
||||
export function workflowStageFormDraftToStageDraft(
|
||||
draft: WorkflowStageFormDraft,
|
||||
): WorkflowStageDraft | null {
|
||||
const retryPolicy = parseJsonObject(draft.retryPolicyJson);
|
||||
if (retryPolicy === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: WorkflowStagePart[] = [];
|
||||
for (const part of draft.parts) {
|
||||
const payload = parseJsonObject(part.payloadJson);
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
parts.push({
|
||||
key: part.key,
|
||||
type: part.type,
|
||||
title: part.title,
|
||||
payload,
|
||||
acceptance_criteria: splitLines(part.acceptanceCriteriaText),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
stableKey: draft.stableKey,
|
||||
displayName: draft.displayName,
|
||||
description: draft.description,
|
||||
position: draft.position,
|
||||
ownerRole: draft.ownerRole,
|
||||
runnerProfileKey: draft.runnerProfileKey,
|
||||
requiredInputs: splitLines(draft.requiredInputsText),
|
||||
expectedOutputs: splitLines(draft.expectedOutputsText),
|
||||
acceptanceCriteria: splitLines(draft.acceptanceCriteriaText),
|
||||
requiresHumanApproval: draft.requiresHumanApproval,
|
||||
retryPolicy,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
|
||||
export function splitLines(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
parsed !== null
|
||||
) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import type {
|
||||
Role,
|
||||
WorkflowStageSummary,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateSummary,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
activateWorkflowTemplate,
|
||||
addWorkflowStage,
|
||||
archiveWorkflowTemplate,
|
||||
createWorkflowTemplate,
|
||||
deleteWorkflowStage,
|
||||
fetchWorkflowTemplate,
|
||||
fetchWorkflowTemplateAuditEvents,
|
||||
fetchWorkflowTemplates,
|
||||
reorderWorkflowStages,
|
||||
updateWorkflowStage,
|
||||
updateWorkflowTemplate,
|
||||
} from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildAdminWorkflowAuditRows,
|
||||
buildAdminWorkflowRows,
|
||||
buildCreateWorkflowPayload,
|
||||
buildCreateWorkflowStagePayload,
|
||||
buildReorderWorkflowStagesPayload,
|
||||
buildUpdateWorkflowPayload,
|
||||
buildUpdateWorkflowStagePayload,
|
||||
defaultWorkflowDraft,
|
||||
defaultWorkflowStageFormDraft,
|
||||
moveWorkflowStage,
|
||||
parseJsonObject,
|
||||
workflowDraftFromSummary,
|
||||
workflowStageFormDraftFromSummary,
|
||||
workflowStageFormDraftToStageDraft,
|
||||
type StageDraftValidationErrors,
|
||||
type WorkflowDraft,
|
||||
type WorkflowStageFormDraft,
|
||||
type WorkflowStageFormPart,
|
||||
} from "./model";
|
||||
|
||||
type AdminWorkflowBuilderProps = {
|
||||
initialWorkflows: readonly WorkflowTemplateSummary[];
|
||||
initialWorkflow?: WorkflowTemplateSummary | null;
|
||||
initialAuditEvents?: readonly WorkflowTemplateAuditEventSummary[];
|
||||
};
|
||||
|
||||
const EMPTY_AUDIT_EVENTS: readonly WorkflowTemplateAuditEventSummary[] = [];
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function sortedStages(
|
||||
stages: readonly WorkflowStageSummary[] | undefined,
|
||||
): WorkflowStageSummary[] {
|
||||
return [...(stages ?? [])].sort((left, right) => left.position - right.position);
|
||||
}
|
||||
|
||||
function createDefaultPart(index: number): WorkflowStageFormPart {
|
||||
return {
|
||||
key: `stage-part-${index}`,
|
||||
type: "instruction",
|
||||
title: "Stage instruction",
|
||||
payloadJson: JSON.stringify({ prompt: "", config: {} }, null, 2),
|
||||
acceptanceCriteriaText: "",
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function AdminWorkflowBuilder({
|
||||
initialWorkflows,
|
||||
initialWorkflow = null,
|
||||
initialAuditEvents = EMPTY_AUDIT_EVENTS,
|
||||
}: AdminWorkflowBuilderProps) {
|
||||
const [workflows, setWorkflows] = useState<readonly WorkflowTemplateSummary[]>(
|
||||
initialWorkflows,
|
||||
);
|
||||
const [selectedWorkflow, setSelectedWorkflow] =
|
||||
useState<WorkflowTemplateSummary | null>(initialWorkflow);
|
||||
const [auditEvents, setAuditEvents] =
|
||||
useState<readonly WorkflowTemplateAuditEventSummary[]>(initialAuditEvents);
|
||||
const [createDraft, setCreateDraft] = useState<WorkflowDraft>(
|
||||
defaultWorkflowDraft(),
|
||||
);
|
||||
const [metadataDraft, setMetadataDraft] = useState<WorkflowDraft>(
|
||||
initialWorkflow ? workflowDraftFromSummary(initialWorkflow) : defaultWorkflowDraft(),
|
||||
);
|
||||
const [editingStageId, setEditingStageId] = useState<string | null>(null);
|
||||
const [stageDraft, setStageDraft] = useState<WorkflowStageFormDraft>(
|
||||
defaultWorkflowStageFormDraft(sortedStages(initialWorkflow?.stages).length + 1),
|
||||
);
|
||||
const [stageErrors, setStageErrors] = useState<StageDraftValidationErrors>({});
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState("");
|
||||
|
||||
const rows = useMemo(() => buildAdminWorkflowRows(workflows), [workflows]);
|
||||
const auditRows = useMemo(
|
||||
() => buildAdminWorkflowAuditRows(auditEvents),
|
||||
[auditEvents],
|
||||
);
|
||||
const stages = useMemo(
|
||||
() => sortedStages(selectedWorkflow?.stages),
|
||||
[selectedWorkflow],
|
||||
);
|
||||
const canEditSelected = selectedWorkflow?.status === "DRAFT";
|
||||
const isBusy = pendingAction !== "";
|
||||
|
||||
function clearMessages() {
|
||||
setActionMessage("");
|
||||
setFormError("");
|
||||
}
|
||||
|
||||
function setActiveWorkflow(
|
||||
workflow: WorkflowTemplateSummary,
|
||||
nextAuditEvents: readonly WorkflowTemplateAuditEventSummary[] = auditEvents,
|
||||
) {
|
||||
const nextStages = sortedStages(workflow.stages);
|
||||
setSelectedWorkflow(workflow);
|
||||
setMetadataDraft(workflowDraftFromSummary(workflow));
|
||||
setAuditEvents(nextAuditEvents);
|
||||
setEditingStageId(null);
|
||||
setStageDraft(defaultWorkflowStageFormDraft(nextStages.length + 1));
|
||||
setStageErrors({});
|
||||
}
|
||||
|
||||
function replaceWorkflowInList(workflow: WorkflowTemplateSummary) {
|
||||
setWorkflows((current) => {
|
||||
const exists = current.some((item) => item.id === workflow.id);
|
||||
if (!exists) {
|
||||
return [workflow, ...current];
|
||||
}
|
||||
return current.map((item) => (item.id === workflow.id ? workflow : item));
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshWorkflowList(selectedWorkflowId?: string) {
|
||||
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
|
||||
setWorkflows(listResponse.workflows);
|
||||
if (selectedWorkflowId) {
|
||||
await loadWorkflow(selectedWorkflowId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkflow(workflowId: string, markPending = true) {
|
||||
if (markPending) {
|
||||
clearMessages();
|
||||
setPendingAction(`load-${workflowId}`);
|
||||
}
|
||||
try {
|
||||
const [workflowResponse, auditResponse] = await Promise.all([
|
||||
fetchWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL),
|
||||
fetchWorkflowTemplateAuditEvents(workflowId, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
setActiveWorkflow(workflowResponse.workflow, auditResponse.events ?? []);
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to load workflow"));
|
||||
} finally {
|
||||
if (markPending) {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
clearMessages();
|
||||
setPendingAction("refresh-workflows");
|
||||
try {
|
||||
await refreshWorkflowList(selectedWorkflow?.id);
|
||||
setActionMessage("Workflow list refreshed");
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to refresh workflow list"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
function updateCreateDraft<K extends keyof WorkflowDraft>(
|
||||
field: K,
|
||||
value: WorkflowDraft[K],
|
||||
) {
|
||||
setCreateDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateMetadataDraft<K extends keyof WorkflowDraft>(
|
||||
field: K,
|
||||
value: WorkflowDraft[K],
|
||||
) {
|
||||
setMetadataDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateStageDraft<K extends keyof WorkflowStageFormDraft>(
|
||||
field: K,
|
||||
value: WorkflowStageFormDraft[K],
|
||||
) {
|
||||
setStageDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateStagePart<K extends keyof WorkflowStageFormPart>(
|
||||
index: number,
|
||||
field: K,
|
||||
value: WorkflowStageFormPart[K],
|
||||
) {
|
||||
setStageDraft((current) => ({
|
||||
...current,
|
||||
parts: current.parts.map((part, partIndex) =>
|
||||
partIndex === index ? { ...part, [field]: value } : part,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function addStagePart() {
|
||||
setStageDraft((current) => ({
|
||||
...current,
|
||||
parts: [...current.parts, createDefaultPart(current.parts.length + 1)],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeStagePart(index: number) {
|
||||
setStageDraft((current) => ({
|
||||
...current,
|
||||
parts: current.parts.filter((_, partIndex) => partIndex !== index),
|
||||
}));
|
||||
}
|
||||
|
||||
function startNewStage() {
|
||||
setEditingStageId(null);
|
||||
setStageErrors({});
|
||||
setStageDraft(defaultWorkflowStageFormDraft(stages.length + 1));
|
||||
}
|
||||
|
||||
function startEditStage(stage: WorkflowStageSummary) {
|
||||
setEditingStageId(stage.id);
|
||||
setStageErrors({});
|
||||
setStageDraft(workflowStageFormDraftFromSummary(stage));
|
||||
}
|
||||
|
||||
function validateStageDraft(): ReturnType<typeof workflowStageFormDraftToStageDraft> {
|
||||
const nextErrors: StageDraftValidationErrors = {};
|
||||
if (!stageDraft.stableKey.trim()) {
|
||||
nextErrors.stableKey = "Stable key is required";
|
||||
}
|
||||
if (!stageDraft.displayName.trim()) {
|
||||
nextErrors.displayName = "Display name is required";
|
||||
}
|
||||
if (!stageDraft.description.trim()) {
|
||||
nextErrors.description = "Description is required";
|
||||
}
|
||||
if (!stageDraft.runnerProfileKey.trim()) {
|
||||
nextErrors.runnerProfileKey = "Runner profile key is required";
|
||||
}
|
||||
if (parseJsonObject(stageDraft.retryPolicyJson) === null) {
|
||||
nextErrors.retryPolicyJson = "Invalid JSON object";
|
||||
}
|
||||
const hasInvalidPartPayload = stageDraft.parts.some(
|
||||
(part) => parseJsonObject(part.payloadJson) === null,
|
||||
);
|
||||
const hasInvalidPartFields = stageDraft.parts.some(
|
||||
(part) => !part.key.trim() || !part.type.trim() || !part.title.trim(),
|
||||
);
|
||||
if (hasInvalidPartPayload || hasInvalidPartFields) {
|
||||
nextErrors.parts = hasInvalidPartPayload
|
||||
? "Each part payload must be a JSON object"
|
||||
: "Each part needs key, type, and title";
|
||||
}
|
||||
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setStageErrors(nextErrors);
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextDraft = workflowStageFormDraftToStageDraft(stageDraft);
|
||||
if (!nextDraft) {
|
||||
setStageErrors({ parts: "Stage draft contains invalid structured data" });
|
||||
return null;
|
||||
}
|
||||
|
||||
setStageErrors({});
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
async function handleCreateWorkflow(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
clearMessages();
|
||||
setPendingAction("create-workflow");
|
||||
try {
|
||||
const response = await createWorkflowTemplate(
|
||||
buildCreateWorkflowPayload(createDraft),
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setCreateDraft(defaultWorkflowDraft());
|
||||
replaceWorkflowInList(response.workflow);
|
||||
setActiveWorkflow(response.workflow, []);
|
||||
setActionMessage("Workflow draft created");
|
||||
await refreshWorkflowList(response.workflow.id);
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to create workflow draft"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveMetadata(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
setPendingAction("save-metadata");
|
||||
try {
|
||||
const response = await updateWorkflowTemplate(
|
||||
selectedWorkflow.id,
|
||||
buildUpdateWorkflowPayload(metadataDraft),
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
replaceWorkflowInList(response.workflow);
|
||||
const auditResponse = await fetchWorkflowTemplateAuditEvents(
|
||||
response.workflow.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
|
||||
setActionMessage("Workflow metadata saved");
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to save workflow metadata"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveStage(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validDraft = validateStageDraft();
|
||||
if (!validDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
setPendingAction("save-stage");
|
||||
try {
|
||||
if (editingStageId) {
|
||||
await updateWorkflowStage(
|
||||
selectedWorkflow.id,
|
||||
editingStageId,
|
||||
buildUpdateWorkflowStagePayload(validDraft),
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActionMessage("Stage updated");
|
||||
} else {
|
||||
await addWorkflowStage(
|
||||
selectedWorkflow.id,
|
||||
buildCreateWorkflowStagePayload(validDraft),
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActionMessage("Stage added");
|
||||
}
|
||||
await refreshWorkflowList(selectedWorkflow.id);
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to save workflow stage"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteStage(stageId: string) {
|
||||
if (!selectedWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
setPendingAction(`delete-stage-${stageId}`);
|
||||
try {
|
||||
const response = await deleteWorkflowStage(
|
||||
selectedWorkflow.id,
|
||||
stageId,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
replaceWorkflowInList(response.workflow);
|
||||
const auditResponse = await fetchWorkflowTemplateAuditEvents(
|
||||
response.workflow.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
|
||||
setActionMessage("Stage deleted");
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to delete workflow stage"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorderStage(stageId: string, targetIndex: number) {
|
||||
if (!selectedWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedStages = moveWorkflowStage(stages, stageId, targetIndex);
|
||||
clearMessages();
|
||||
setPendingAction(`reorder-${stageId}`);
|
||||
try {
|
||||
const response = await reorderWorkflowStages(
|
||||
selectedWorkflow.id,
|
||||
buildReorderWorkflowStagesPayload(movedStages),
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
replaceWorkflowInList(response.workflow);
|
||||
const auditResponse = await fetchWorkflowTemplateAuditEvents(
|
||||
response.workflow.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
|
||||
setActionMessage("Stage order saved");
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to reorder workflow stages"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivateWorkflow(workflowId: string) {
|
||||
clearMessages();
|
||||
setPendingAction(`activate-${workflowId}`);
|
||||
try {
|
||||
const response = await activateWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
|
||||
replaceWorkflowInList(response.workflow);
|
||||
const auditResponse = await fetchWorkflowTemplateAuditEvents(
|
||||
response.workflow.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
|
||||
setActionMessage("Workflow activated");
|
||||
await refreshWorkflowList(response.workflow.id);
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to activate workflow"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveWorkflow(workflowId: string) {
|
||||
clearMessages();
|
||||
setPendingAction(`archive-${workflowId}`);
|
||||
try {
|
||||
const response = await archiveWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
|
||||
replaceWorkflowInList(response.workflow);
|
||||
const auditResponse = await fetchWorkflowTemplateAuditEvents(
|
||||
response.workflow.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
|
||||
setActionMessage("Workflow archived");
|
||||
await refreshWorkflowList(response.workflow.id);
|
||||
} catch (error) {
|
||||
setFormError(errorMessage(error, "Unable to archive workflow"));
|
||||
} finally {
|
||||
setPendingAction("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
|
||||
|
||||
<section className="panel">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Workflow templates</h2>
|
||||
<p>{rows.length} templates</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleRefresh} disabled={isBusy}>
|
||||
{pendingAction === "refresh-workflows" ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="articleForm" onSubmit={handleCreateWorkflow}>
|
||||
<h3>Create draft</h3>
|
||||
<label htmlFor="workflowName">Name</label>
|
||||
<input
|
||||
id="workflowName"
|
||||
value={createDraft.name}
|
||||
onChange={(event) => updateCreateDraft("name", event.target.value)}
|
||||
placeholder="Article production workflow"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="workflowSlug">Slug</label>
|
||||
<input
|
||||
id="workflowSlug"
|
||||
value={createDraft.slug}
|
||||
onChange={(event) => updateCreateDraft("slug", event.target.value)}
|
||||
placeholder="article-production-v1"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="workflowDescription">Description</label>
|
||||
<textarea
|
||||
id="workflowDescription"
|
||||
value={createDraft.description}
|
||||
onChange={(event) => updateCreateDraft("description", event.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={isBusy}>
|
||||
{pendingAction === "create-workflow" ? "Creating..." : "Create draft"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="emptyState">No workflow templates yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Version</th>
|
||||
<th>Stages</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<strong>{row.name}</strong>
|
||||
<div>{row.slug}</div>
|
||||
</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.version}</td>
|
||||
<td>{row.stageCount}</td>
|
||||
<td>{formatDate(row.updatedAt)}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadWorkflow(row.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
{row.isDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActivateWorkflow(row.id)}
|
||||
disabled={isBusy}
|
||||
className="tableAction"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
) : null}
|
||||
{!row.isArchived ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleArchiveWorkflow(row.id)}
|
||||
disabled={isBusy}
|
||||
className="tableAction"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Workflow editor</h2>
|
||||
<p>
|
||||
{selectedWorkflow
|
||||
? `${selectedWorkflow.name} - ${selectedWorkflow.status}`
|
||||
: "Select a workflow"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedWorkflow ? (
|
||||
<p className="emptyState">Open a workflow template to edit stages.</p>
|
||||
) : (
|
||||
<>
|
||||
<form className="articleForm" onSubmit={handleSaveMetadata}>
|
||||
<h3>Metadata</h3>
|
||||
{!canEditSelected ? (
|
||||
<p className="emptyState">Only draft templates can be edited.</p>
|
||||
) : null}
|
||||
<label htmlFor="metadataName">Name</label>
|
||||
<input
|
||||
id="metadataName"
|
||||
value={metadataDraft.name}
|
||||
onChange={(event) => updateMetadataDraft("name", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="metadataSlug">Slug</label>
|
||||
<input
|
||||
id="metadataSlug"
|
||||
value={metadataDraft.slug}
|
||||
onChange={(event) => updateMetadataDraft("slug", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="metadataDescription">Description</label>
|
||||
<textarea
|
||||
id="metadataDescription"
|
||||
value={metadataDraft.description}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraft("description", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canEditSelected || isBusy}>
|
||||
{pendingAction === "save-metadata" ? "Saving..." : "Save metadata"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Stages</h3>
|
||||
{stages.length === 0 ? (
|
||||
<p className="emptyState">No stages yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Stage</th>
|
||||
<th>Owner</th>
|
||||
<th>Runner</th>
|
||||
<th>Approval</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stages.map((stage, index) => (
|
||||
<tr key={stage.id}>
|
||||
<td>{stage.position}</td>
|
||||
<td>
|
||||
<strong>{stage.display_name}</strong>
|
||||
<div>{stage.stable_key}</div>
|
||||
<div>{stage.description}</div>
|
||||
</td>
|
||||
<td>{stage.owner_role}</td>
|
||||
<td>{stage.runner_profile_key}</td>
|
||||
<td>{stage.requires_human_approval ? "Required" : "None"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditStage(stage)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderStage(stage.id, index - 1)}
|
||||
disabled={!canEditSelected || isBusy || index === 0}
|
||||
className="tableAction"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderStage(stage.id, index + 1)}
|
||||
disabled={!canEditSelected || isBusy || index === stages.length - 1}
|
||||
className="tableAction"
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteStage(stage.id)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
className="tableAction"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<form className="articleForm" onSubmit={handleSaveStage}>
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h3>{editingStageId ? "Edit stage" : "Add stage"}</h3>
|
||||
<p>Structured inputs, outputs, retry policy, and parts.</p>
|
||||
</div>
|
||||
<button type="button" onClick={startNewStage} disabled={isBusy}>
|
||||
New stage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label htmlFor="stageStableKey">Stable key</label>
|
||||
<input
|
||||
id="stageStableKey"
|
||||
value={stageDraft.stableKey}
|
||||
onChange={(event) => updateStageDraft("stableKey", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
{stageErrors.stableKey ? (
|
||||
<p className="fieldError">{stageErrors.stableKey}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="stageDisplayName">Display name</label>
|
||||
<input
|
||||
id="stageDisplayName"
|
||||
value={stageDraft.displayName}
|
||||
onChange={(event) => updateStageDraft("displayName", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
{stageErrors.displayName ? (
|
||||
<p className="fieldError">{stageErrors.displayName}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="stageDescription">Description</label>
|
||||
<textarea
|
||||
id="stageDescription"
|
||||
value={stageDraft.description}
|
||||
onChange={(event) => updateStageDraft("description", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
{stageErrors.description ? (
|
||||
<p className="fieldError">{stageErrors.description}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="stagePosition">Position</label>
|
||||
<input
|
||||
id="stagePosition"
|
||||
type="number"
|
||||
min={1}
|
||||
value={stageDraft.position}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("position", Number(event.target.value) || 1)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor="stageOwnerRole">Owner role</label>
|
||||
<select
|
||||
id="stageOwnerRole"
|
||||
value={stageDraft.ownerRole}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("ownerRole", event.target.value as Role)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
>
|
||||
<option value="EDITOR">EDITOR</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
|
||||
<label htmlFor="stageRunnerProfile">Runner profile key</label>
|
||||
<input
|
||||
id="stageRunnerProfile"
|
||||
value={stageDraft.runnerProfileKey}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("runnerProfileKey", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
{stageErrors.runnerProfileKey ? (
|
||||
<p className="fieldError">{stageErrors.runnerProfileKey}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="stageRequiredInputs">Required inputs</label>
|
||||
<textarea
|
||||
id="stageRequiredInputs"
|
||||
value={stageDraft.requiredInputsText}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("requiredInputsText", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor="stageExpectedOutputs">Expected outputs</label>
|
||||
<textarea
|
||||
id="stageExpectedOutputs"
|
||||
value={stageDraft.expectedOutputsText}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("expectedOutputsText", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor="stageAcceptanceCriteria">Acceptance criteria</label>
|
||||
<textarea
|
||||
id="stageAcceptanceCriteria"
|
||||
value={stageDraft.acceptanceCriteriaText}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("acceptanceCriteriaText", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor="stageRequiresApproval">
|
||||
<input
|
||||
id="stageRequiresApproval"
|
||||
type="checkbox"
|
||||
checked={stageDraft.requiresHumanApproval}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("requiresHumanApproval", event.target.checked)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
Requires human approval
|
||||
</label>
|
||||
|
||||
<label htmlFor="stageRetryPolicy">Retry policy JSON</label>
|
||||
<textarea
|
||||
id="stageRetryPolicy"
|
||||
className="codeArea"
|
||||
value={stageDraft.retryPolicyJson}
|
||||
onChange={(event) =>
|
||||
updateStageDraft("retryPolicyJson", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
{stageErrors.retryPolicyJson ? (
|
||||
<p className="fieldError">{stageErrors.retryPolicyJson}</p>
|
||||
) : null}
|
||||
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h3>Stage parts</h3>
|
||||
<p>{stageDraft.parts.length} structured parts</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStagePart}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
>
|
||||
Add part
|
||||
</button>
|
||||
</div>
|
||||
{stageErrors.parts ? <p className="fieldError">{stageErrors.parts}</p> : null}
|
||||
|
||||
{stageDraft.parts.length === 0 ? (
|
||||
<p className="emptyState">No structured parts yet.</p>
|
||||
) : null}
|
||||
|
||||
{stageDraft.parts.map((part, index) => (
|
||||
<div
|
||||
key={`${part.key}-${index}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
borderTop: "1px solid #d0d5dd",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`stagePartKey-${index}`}>Part key</label>
|
||||
<input
|
||||
id={`stagePartKey-${index}`}
|
||||
value={part.key}
|
||||
onChange={(event) => updateStagePart(index, "key", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor={`stagePartType-${index}`}>Part type</label>
|
||||
<input
|
||||
id={`stagePartType-${index}`}
|
||||
value={part.type}
|
||||
onChange={(event) => updateStagePart(index, "type", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor={`stagePartTitle-${index}`}>Part title</label>
|
||||
<input
|
||||
id={`stagePartTitle-${index}`}
|
||||
value={part.title}
|
||||
onChange={(event) => updateStagePart(index, "title", event.target.value)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor={`stagePartPayload-${index}`}>Payload JSON</label>
|
||||
<textarea
|
||||
id={`stagePartPayload-${index}`}
|
||||
className="codeArea"
|
||||
value={part.payloadJson}
|
||||
onChange={(event) =>
|
||||
updateStagePart(index, "payloadJson", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<label htmlFor={`stagePartCriteria-${index}`}>
|
||||
Part acceptance criteria
|
||||
</label>
|
||||
<textarea
|
||||
id={`stagePartCriteria-${index}`}
|
||||
value={part.acceptanceCriteriaText}
|
||||
onChange={(event) =>
|
||||
updateStagePart(index, "acceptanceCriteriaText", event.target.value)
|
||||
}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
/>
|
||||
|
||||
<div className="formActions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStagePart(index)}
|
||||
disabled={!canEditSelected || isBusy}
|
||||
>
|
||||
Remove part
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canEditSelected || isBusy}>
|
||||
{pendingAction === "save-stage" ? "Saving..." : "Save stage"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Audit trail</h3>
|
||||
{auditRows.length === 0 ? (
|
||||
<p className="emptyState">No audit events yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Created</th>
|
||||
<th>Actor</th>
|
||||
<th>Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditRows.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td>{event.eventType}</td>
|
||||
<td>{formatDate(event.createdAt)}</td>
|
||||
<td>{event.actorUserId ?? "none"}</td>
|
||||
<td>{event.payload}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type {
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateSummary,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
import { AdminWorkflowBuilder } from "@/features/admin-workflows/ui";
|
||||
import {
|
||||
DEMO_ADMIN_EMAIL,
|
||||
fetchWorkflowTemplate,
|
||||
fetchWorkflowTemplateAuditEvents,
|
||||
fetchWorkflowTemplates,
|
||||
} from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
|
||||
type SearchParams = {
|
||||
workflowId?: string;
|
||||
};
|
||||
|
||||
type AdminWorkflowsPageProps = {
|
||||
searchParams?: SearchParams;
|
||||
};
|
||||
|
||||
export default async function AdminWorkflowsPage({
|
||||
searchParams,
|
||||
}: AdminWorkflowsPageProps) {
|
||||
const workflowListResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
|
||||
const selectedWorkflowId = searchParams?.workflowId;
|
||||
let selectedWorkflow: WorkflowTemplateSummary | null = null;
|
||||
let auditEvents: WorkflowTemplateAuditEventSummary[] = [];
|
||||
|
||||
if (selectedWorkflowId) {
|
||||
try {
|
||||
const [workflowResponse, auditResponse] = await Promise.all([
|
||||
fetchWorkflowTemplate(selectedWorkflowId, DEMO_ADMIN_EMAIL),
|
||||
fetchWorkflowTemplateAuditEvents(selectedWorkflowId, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
selectedWorkflow = workflowResponse.workflow;
|
||||
auditEvents = auditResponse.events ?? [];
|
||||
} catch {
|
||||
selectedWorkflow = null;
|
||||
auditEvents = [];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Workflows</h1>
|
||||
<p>Create and manage reusable ordered workflow templates.</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
<span className="roleBadge">Admin demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<AdminWorkflowBuilder
|
||||
initialWorkflows={workflowListResponse.workflows}
|
||||
initialWorkflow={selectedWorkflow}
|
||||
initialAuditEvents={auditEvents}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,15 @@ import type {
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigCreateRequest,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowStageResponse,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export const DEMO_EDITOR_EMAIL = "editor@example.com";
|
||||
@@ -184,6 +193,13 @@ export function apiPatch<TRequest, TResponse>(
|
||||
);
|
||||
}
|
||||
|
||||
export function apiDelete<TResponse>(
|
||||
path: string,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<TResponse> {
|
||||
return requestJson<TResponse>(path, { method: "DELETE" }, options?.userEmail);
|
||||
}
|
||||
|
||||
export function fetchCurrentUser(
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<CurrentUserResponse> {
|
||||
@@ -259,6 +275,126 @@ export function createTargetSite(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWorkflowTemplates(
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateListResponse> {
|
||||
return apiGet<WorkflowTemplateListResponse>("/api/admin/workflows", {
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchWorkflowTemplate(
|
||||
workflowId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiGet<WorkflowTemplateResponse>(`/api/admin/workflows/${workflowId}`, {
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkflowTemplate(
|
||||
request: WorkflowTemplateCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiPost<WorkflowTemplateCreateRequest, WorkflowTemplateResponse>(
|
||||
"/api/admin/workflows",
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateWorkflowTemplate(
|
||||
workflowId: string,
|
||||
request: WorkflowTemplateUpdateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiPatch<WorkflowTemplateUpdateRequest, WorkflowTemplateResponse>(
|
||||
`/api/admin/workflows/${workflowId}`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function addWorkflowStage(
|
||||
workflowId: string,
|
||||
request: WorkflowStageCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowStageResponse> {
|
||||
return apiPost<WorkflowStageCreateRequest, WorkflowStageResponse>(
|
||||
`/api/admin/workflows/${workflowId}/stages`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateWorkflowStage(
|
||||
workflowId: string,
|
||||
stageId: string,
|
||||
request: WorkflowStageUpdateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowStageResponse> {
|
||||
return apiPatch<WorkflowStageUpdateRequest, WorkflowStageResponse>(
|
||||
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteWorkflowStage(
|
||||
workflowId: string,
|
||||
stageId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiDelete<WorkflowTemplateResponse>(
|
||||
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function reorderWorkflowStages(
|
||||
workflowId: string,
|
||||
request: WorkflowStageReorderRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiPost<WorkflowStageReorderRequest, WorkflowTemplateResponse>(
|
||||
`/api/admin/workflows/${workflowId}/stages/reorder`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function activateWorkflowTemplate(
|
||||
workflowId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiPost<unknown, WorkflowTemplateResponse>(
|
||||
`/api/admin/workflows/${workflowId}/activate`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function archiveWorkflowTemplate(
|
||||
workflowId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateResponse> {
|
||||
return apiPost<unknown, WorkflowTemplateResponse>(
|
||||
`/api/admin/workflows/${workflowId}/archive`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWorkflowTemplateAuditEvents(
|
||||
workflowId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<WorkflowTemplateAuditEventListResponse> {
|
||||
return apiGet<WorkflowTemplateAuditEventListResponse>(
|
||||
`/api/admin/workflows/${workflowId}/audit`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchScriptConfigVersions(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
|
||||
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
|
||||
href: "/admin/scripts",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
label: "Workflows",
|
||||
href: "/admin/workflows",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "agent-jobs",
|
||||
label: "Agent jobs",
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const failures = [];
|
||||
|
||||
function loadTsModule(sourcePath, requireOverrides = {}) {
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
const module = { exports: moduleExports };
|
||||
const localRequire = (specifier) => {
|
||||
if (specifier in requireOverrides) {
|
||||
return requireOverrides[specifier];
|
||||
}
|
||||
throw new Error(`Unexpected runtime import in model test: ${specifier}`);
|
||||
};
|
||||
|
||||
new Function("exports", "module", "require", compiled.outputText)(
|
||||
moduleExports,
|
||||
module,
|
||||
localRequire,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
function loadExpectedModule(sourcePath, label, expectedExports = []) {
|
||||
if (!existsSync(sourcePath)) {
|
||||
const expected = expectedExports.join(", ");
|
||||
failures.push(
|
||||
`${label} is missing at ${path.relative(process.cwd(), sourcePath)}; expected exports: ${expected}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return loadTsModule(sourcePath);
|
||||
} catch (error) {
|
||||
failures.push(`${label} failed to load: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectFunction(moduleExports, exportName, label) {
|
||||
if (!moduleExports) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = moduleExports?.[exportName];
|
||||
if (typeof value !== "function") {
|
||||
failures.push(`${label} must export ${exportName}()`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function recordAssertion(label, assertion) {
|
||||
try {
|
||||
await assertion();
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workflowModelPath = path.resolve(
|
||||
testDir,
|
||||
"../src/features/admin-workflows/model.ts",
|
||||
);
|
||||
const apiPath = path.resolve(testDir, "../src/shared/pipeline-api.ts");
|
||||
const navigationModelPath = path.resolve(
|
||||
testDir,
|
||||
"../src/widgets/role-navigation/model.ts",
|
||||
);
|
||||
|
||||
const expectedWorkflowModelExports = [
|
||||
"defaultWorkflowDraft",
|
||||
"buildCreateWorkflowPayload",
|
||||
"defaultWorkflowStageDraft",
|
||||
"buildCreateWorkflowStagePayload",
|
||||
"moveWorkflowStage",
|
||||
"buildReorderWorkflowStagesPayload",
|
||||
];
|
||||
|
||||
const workflowDraft = {
|
||||
name: "Article production workflow",
|
||||
slug: "article-production-v1",
|
||||
description: "Reusable editorial workflow for longform article production.",
|
||||
};
|
||||
const workflowId = "11111111-1111-1111-1111-111111111111";
|
||||
const intakeStageId = "22222222-2222-2222-2222-222222222222";
|
||||
const draftStageId = "33333333-3333-3333-3333-333333333333";
|
||||
const workflowPart = {
|
||||
key: "required-context-checklist",
|
||||
type: "checklist",
|
||||
title: "Required context checklist",
|
||||
payload: {
|
||||
prompt: "Identify missing audience, keyword, and source constraints.",
|
||||
config: {
|
||||
required_fields: ["audience", "primary_keyword", "source_rules"],
|
||||
block_on_missing: true,
|
||||
},
|
||||
},
|
||||
acceptance_criteria: [
|
||||
"Missing required context is listed explicitly.",
|
||||
"No free-text parsing is needed to read the checklist.",
|
||||
],
|
||||
};
|
||||
const intakeStageDraft = {
|
||||
stableKey: "intake-boundary-questions",
|
||||
displayName: "Boundary question intake",
|
||||
description: "Collect required editorial context before planning.",
|
||||
position: 1,
|
||||
ownerRole: "EDITOR",
|
||||
runnerProfileKey: "boundary-question-agent-v1",
|
||||
requiredInputs: ["article_brief", "target_site"],
|
||||
expectedOutputs: ["answered_boundary_questions"],
|
||||
acceptanceCriteria: ["All required boundary questions are answered."],
|
||||
requiresHumanApproval: true,
|
||||
retryPolicy: { max_attempts: 1, backoff_seconds: 0 },
|
||||
parts: [workflowPart],
|
||||
};
|
||||
const expectedIntakeStagePayload = {
|
||||
stable_key: "intake-boundary-questions",
|
||||
display_name: "Boundary question intake",
|
||||
description: "Collect required editorial context before planning.",
|
||||
position: 1,
|
||||
owner_role: "EDITOR",
|
||||
runner_profile_key: "boundary-question-agent-v1",
|
||||
required_inputs: ["article_brief", "target_site"],
|
||||
expected_outputs: ["answered_boundary_questions"],
|
||||
acceptance_criteria: ["All required boundary questions are answered."],
|
||||
requires_human_approval: true,
|
||||
retry_policy: { max_attempts: 1, backoff_seconds: 0 },
|
||||
parts: [workflowPart],
|
||||
};
|
||||
|
||||
const workflowModelExports = loadExpectedModule(
|
||||
workflowModelPath,
|
||||
"features/admin-workflows/model",
|
||||
expectedWorkflowModelExports,
|
||||
);
|
||||
const apiExports = loadExpectedModule(apiPath, "shared/pipeline-api");
|
||||
const navigationExports = loadExpectedModule(
|
||||
navigationModelPath,
|
||||
"widgets/role-navigation/model",
|
||||
);
|
||||
|
||||
const defaultWorkflowDraft = expectFunction(
|
||||
workflowModelExports,
|
||||
"defaultWorkflowDraft",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const buildCreateWorkflowPayload = expectFunction(
|
||||
workflowModelExports,
|
||||
"buildCreateWorkflowPayload",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const defaultWorkflowStageDraft = expectFunction(
|
||||
workflowModelExports,
|
||||
"defaultWorkflowStageDraft",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const buildCreateWorkflowStagePayload = expectFunction(
|
||||
workflowModelExports,
|
||||
"buildCreateWorkflowStagePayload",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const moveWorkflowStage = expectFunction(
|
||||
workflowModelExports,
|
||||
"moveWorkflowStage",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const buildReorderWorkflowStagesPayload = expectFunction(
|
||||
workflowModelExports,
|
||||
"buildReorderWorkflowStagesPayload",
|
||||
"features/admin-workflows/model",
|
||||
);
|
||||
const createWorkflowTemplate = expectFunction(
|
||||
apiExports,
|
||||
"createWorkflowTemplate",
|
||||
"shared/pipeline-api",
|
||||
);
|
||||
const addWorkflowStage = expectFunction(
|
||||
apiExports,
|
||||
"addWorkflowStage",
|
||||
"shared/pipeline-api",
|
||||
);
|
||||
const reorderWorkflowStages = expectFunction(
|
||||
apiExports,
|
||||
"reorderWorkflowStages",
|
||||
"shared/pipeline-api",
|
||||
);
|
||||
const getRoleNavigationItems = expectFunction(
|
||||
navigationExports,
|
||||
"getRoleNavigationItems",
|
||||
"widgets/role-navigation/model",
|
||||
);
|
||||
|
||||
if (
|
||||
defaultWorkflowDraft &&
|
||||
buildCreateWorkflowPayload &&
|
||||
defaultWorkflowStageDraft &&
|
||||
buildCreateWorkflowStagePayload &&
|
||||
moveWorkflowStage &&
|
||||
buildReorderWorkflowStagesPayload
|
||||
) {
|
||||
await recordAssertion("workflow draft payload maps model state to API request", () => {
|
||||
const createPayload = buildCreateWorkflowPayload({
|
||||
...defaultWorkflowDraft(),
|
||||
...workflowDraft,
|
||||
});
|
||||
assert.deepEqual(createPayload, workflowDraft);
|
||||
});
|
||||
|
||||
await recordAssertion("workflow stage payload preserves structured parts", () => {
|
||||
const stagePayload = buildCreateWorkflowStagePayload({
|
||||
...defaultWorkflowStageDraft(),
|
||||
...intakeStageDraft,
|
||||
});
|
||||
assert.deepEqual(stagePayload, expectedIntakeStagePayload);
|
||||
});
|
||||
|
||||
await recordAssertion("workflow stage reorder returns ordered IDs for API", () => {
|
||||
const stages = [
|
||||
{ id: intakeStageId, stableKey: "intake-boundary-questions", position: 1 },
|
||||
{ id: draftStageId, stableKey: "draft-assembly", position: 2 },
|
||||
];
|
||||
const reorderedStages = moveWorkflowStage(stages, draftStageId, 0);
|
||||
assert.deepEqual(
|
||||
reorderedStages.map((stage) => ({ id: stage.id, position: stage.position })),
|
||||
[
|
||||
{ id: draftStageId, position: 1 },
|
||||
{ id: intakeStageId, position: 2 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(buildReorderWorkflowStagesPayload(reorderedStages), {
|
||||
stage_ids: [draftStageId, intakeStageId],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (createWorkflowTemplate && addWorkflowStage && reorderWorkflowStages) {
|
||||
await recordAssertion("workflow API client posts create/add/reorder requests", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
const responses = [
|
||||
{
|
||||
workflow: {
|
||||
id: workflowId,
|
||||
...workflowDraft,
|
||||
version: 1,
|
||||
status: "DRAFT",
|
||||
stages: [],
|
||||
created_at: "2026-05-22T00:00:00Z",
|
||||
updated_at: "2026-05-22T00:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
stage: {
|
||||
id: intakeStageId,
|
||||
workflow_id: workflowId,
|
||||
...expectedIntakeStagePayload,
|
||||
},
|
||||
},
|
||||
{
|
||||
stage: {
|
||||
id: draftStageId,
|
||||
workflow_id: workflowId,
|
||||
...expectedIntakeStagePayload,
|
||||
stable_key: "draft-assembly",
|
||||
display_name: "Draft assembly",
|
||||
position: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
workflow: {
|
||||
id: workflowId,
|
||||
...workflowDraft,
|
||||
version: 1,
|
||||
status: "DRAFT",
|
||||
stages: [
|
||||
{ id: draftStageId, stable_key: "draft-assembly", position: 1 },
|
||||
{
|
||||
id: intakeStageId,
|
||||
stable_key: "intake-boundary-questions",
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
created_at: "2026-05-22T00:00:00Z",
|
||||
updated_at: "2026-05-22T00:00:00Z",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({
|
||||
path: new URL(String(url)).pathname,
|
||||
method: options.method,
|
||||
userEmail: options.headers["X-Demo-User-Email"],
|
||||
body: options.body ? JSON.parse(options.body) : undefined,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
text: async () => JSON.stringify(responses.shift()),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await createWorkflowTemplate(workflowDraft);
|
||||
const intakeStage = await addWorkflowStage(
|
||||
created.workflow.id,
|
||||
expectedIntakeStagePayload,
|
||||
);
|
||||
const draftStage = await addWorkflowStage(created.workflow.id, {
|
||||
...expectedIntakeStagePayload,
|
||||
stable_key: "draft-assembly",
|
||||
display_name: "Draft assembly",
|
||||
position: 2,
|
||||
});
|
||||
const reordered = await reorderWorkflowStages(created.workflow.id, {
|
||||
stage_ids: [draftStage.stage.id, intakeStage.stage.id],
|
||||
});
|
||||
|
||||
assert.equal(created.workflow.status, "DRAFT");
|
||||
assert.deepEqual(
|
||||
reordered.workflow.stages.map((stage) => stage.id),
|
||||
[draftStageId, intakeStageId],
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
path: "/api/admin/workflows",
|
||||
method: "POST",
|
||||
userEmail: "admin@example.com",
|
||||
body: workflowDraft,
|
||||
},
|
||||
{
|
||||
path: `/api/admin/workflows/${workflowId}/stages`,
|
||||
method: "POST",
|
||||
userEmail: "admin@example.com",
|
||||
body: expectedIntakeStagePayload,
|
||||
},
|
||||
{
|
||||
path: `/api/admin/workflows/${workflowId}/stages`,
|
||||
method: "POST",
|
||||
userEmail: "admin@example.com",
|
||||
body: {
|
||||
...expectedIntakeStagePayload,
|
||||
stable_key: "draft-assembly",
|
||||
display_name: "Draft assembly",
|
||||
position: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: `/api/admin/workflows/${workflowId}/stages/reorder`,
|
||||
method: "POST",
|
||||
userEmail: "admin@example.com",
|
||||
body: { stage_ids: [draftStageId, intakeStageId] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
if (getRoleNavigationItems) {
|
||||
await recordAssertion("Admin navigation exposes Workflows builder route", () => {
|
||||
const adminItems = getRoleNavigationItems("ADMIN");
|
||||
assert.deepEqual(
|
||||
adminItems.find((item) => item.id === "workflows"),
|
||||
{
|
||||
id: "workflows",
|
||||
label: "Workflows",
|
||||
href: "/admin/workflows",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await recordAssertion("Editor navigation does not expose Workflows", () => {
|
||||
const editorItems = getRoleNavigationItems("EDITOR");
|
||||
assert.equal(
|
||||
editorItems.some(
|
||||
(item) =>
|
||||
item.id === "workflows" ||
|
||||
item.href === "/admin/workflows" ||
|
||||
item.label === "Workflows",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
assert.fail(
|
||||
`Admin workflow builder frontend pre-requirements missing:\n- ${failures.join("\n- ")}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user