From baff9211e88b1d7bc69141b9998db95b83523d39 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 22 May 2026 04:05:47 +0300 Subject: [PATCH] feat(task-022): add admin workflow builder ui --- apps/frontend/next.config.mjs | 9 + apps/frontend/package.json | 2 +- .../frontend/src/app/admin/workflows/page.tsx | 3 + .../src/features/admin-workflows/model.ts | 363 +++++++ .../src/features/admin-workflows/ui.tsx | 976 ++++++++++++++++++ .../src/pages/admin-workflows/index.tsx | 65 ++ apps/frontend/src/shared/pipeline-api.ts | 136 +++ .../src/widgets/role-navigation/model.ts | 6 + .../tests/admin_workflows.model.test.mjs | 411 ++++++++ packages/shared/src/api-types.ts | 116 +++ tasks/022-admin-workflow-builder-ui.md | 33 +- 11 files changed, 2105 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/app/admin/workflows/page.tsx create mode 100644 apps/frontend/src/features/admin-workflows/model.ts create mode 100644 apps/frontend/src/features/admin-workflows/ui.tsx create mode 100644 apps/frontend/src/pages/admin-workflows/index.tsx create mode 100644 apps/frontend/tests/admin_workflows.model.test.mjs diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index d5456a1..3025cc3 100644 --- a/apps/frontend/next.config.mjs +++ b/apps/frontend/next.config.mjs @@ -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; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 0d1a0a5..eb486b9 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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": { diff --git a/apps/frontend/src/app/admin/workflows/page.tsx b/apps/frontend/src/app/admin/workflows/page.tsx new file mode 100644 index 0000000..9bc4239 --- /dev/null +++ b/apps/frontend/src/app/admin/workflows/page.tsx @@ -0,0 +1,3 @@ +import AdminWorkflowsPage from "@/pages/admin-workflows"; + +export { AdminWorkflowsPage as default }; diff --git a/apps/frontend/src/features/admin-workflows/model.ts b/apps/frontend/src/features/admin-workflows/model.ts new file mode 100644 index 0000000..159be79 --- /dev/null +++ b/apps/frontend/src/features/admin-workflows/model.ts @@ -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; + parts: WorkflowStagePart[]; +}; + +export type WorkflowStageFormPart = { + key: string; + type: string; + title: string; + payloadJson: string; + acceptanceCriteriaText: string; +}; + +export type WorkflowStageFormDraft = Omit & { + 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( + 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 | 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; + } + } catch { + return null; + } + + return null; +} diff --git a/apps/frontend/src/features/admin-workflows/ui.tsx b/apps/frontend/src/features/admin-workflows/ui.tsx new file mode 100644 index 0000000..4f689ce --- /dev/null +++ b/apps/frontend/src/features/admin-workflows/ui.tsx @@ -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( + initialWorkflows, + ); + const [selectedWorkflow, setSelectedWorkflow] = + useState(initialWorkflow); + const [auditEvents, setAuditEvents] = + useState(initialAuditEvents); + const [createDraft, setCreateDraft] = useState( + defaultWorkflowDraft(), + ); + const [metadataDraft, setMetadataDraft] = useState( + initialWorkflow ? workflowDraftFromSummary(initialWorkflow) : defaultWorkflowDraft(), + ); + const [editingStageId, setEditingStageId] = useState(null); + const [stageDraft, setStageDraft] = useState( + defaultWorkflowStageFormDraft(sortedStages(initialWorkflow?.stages).length + 1), + ); + const [stageErrors, setStageErrors] = useState({}); + 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( + field: K, + value: WorkflowDraft[K], + ) { + setCreateDraft((current) => ({ ...current, [field]: value })); + } + + function updateMetadataDraft( + field: K, + value: WorkflowDraft[K], + ) { + setMetadataDraft((current) => ({ ...current, [field]: value })); + } + + function updateStageDraft( + field: K, + value: WorkflowStageFormDraft[K], + ) { + setStageDraft((current) => ({ ...current, [field]: value })); + } + + function updateStagePart( + 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 { + 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) { + 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) { + 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) { + 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 ?

Error: {formError}

: null} + {actionMessage ?

{actionMessage}

: null} + +
+
+
+

Workflow templates

+

{rows.length} templates

+
+ +
+ +
+

Create draft

+ + updateCreateDraft("name", event.target.value)} + placeholder="Article production workflow" + required + /> + + + updateCreateDraft("slug", event.target.value)} + placeholder="article-production-v1" + required + /> + + +