feat: persist and edit versioned workflow definitions

This commit is contained in:
2026-07-28 13:48:06 +02:00
parent 85eef00913
commit 6737b60c11
25 changed files with 3913 additions and 9 deletions
@@ -0,0 +1,703 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type DragEvent
} from "react";
import {
Archive,
CheckCircle2,
GitFork,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import {
Button,
ConfirmDialog,
DismissibleAlert,
IconButton,
LoadingFrame,
StatusBadge,
hasScope,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
activateWorkflowDefinition,
archiveWorkflowDefinition,
createWorkflowDefinition,
deleteWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
updateWorkflowDefinition,
validateWorkflowDefinition,
type WorkflowDefinition,
type WorkflowDiagnostic,
type WorkflowNodeType,
type WorkflowRevision
} from "../../api/workflow";
import WorkflowCanvas, {
updateWorkflowGraphNode
} from "./WorkflowCanvas";
import WorkflowInspector from "./WorkflowInspector";
import {
FALLBACK_WORKFLOW_LIBRARY,
draftFromDefinition,
sampleWorkflowDraft,
workflowFingerprint,
workflowPayload,
type WorkflowDraft
} from "./model";
const CATEGORY_ORDER = [
"trigger",
"activity",
"decision",
"wait",
"integration",
"outcome"
];
export default function WorkflowPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const { requestNavigation } = useUnsavedChanges();
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
const [savedDraft, setSavedDraft] = useState<WorkflowDraft | null>(null);
const [revisions, setRevisions] = useState<WorkflowRevision[]>([]);
const [historicalRevision, setHistoricalRevision] =
useState<WorkflowRevision | null>(null);
const [nodeLibrary, setNodeLibrary] = useState<WorkflowNodeType[]>(
FALLBACK_WORKFLOW_LIBRARY
);
const [allowsCycles, setAllowsCycles] = useState(true);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
const [deleteOpen, setDeleteOpen] = useState(false);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canWrite || historicalRevision !== null;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
);
const visibleDefinitions = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return definitions;
return definitions.filter((definition) =>
`${definition.name} ${definition.key} ${definition.description ?? ""} ${definition.status}`
.toLocaleLowerCase()
.includes(query)
);
}, [definitions, search]);
const paletteGroups = useMemo(
() => CATEGORY_ORDER.map((category) => ({
category,
label: nodeLibrary.find((item) => item.category === category)
?.category_label ?? category,
nodes: nodeLibrary.filter((item) => item.category === category)
})).filter((group) => group.nodes.length),
[nodeLibrary]
);
const applyDefinition = useCallback((definition: WorkflowDefinition) => {
const next = draftFromDefinition(definition);
setDraft(next);
setSavedDraft(structuredClone(next));
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
}, []);
const reload = useCallback(async (preferredId?: string | null) => {
setLoading(true);
setError("");
try {
const items = await listWorkflowDefinitions(settings);
setDefinitions(items);
const selected = items.find((item) => item.id === preferredId) ?? items[0];
if (selected) {
applyDefinition(selected);
} else {
setDraft(null);
setSavedDraft(null);
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(null);
}
} catch (loadError) {
setError(apiErrorMessage(loadError));
} finally {
setLoading(false);
}
}, [applyDefinition, settings]);
useEffect(() => {
void reload();
let cancelled = false;
void listWorkflowNodeTypes(settings)
.then((library) => {
if (cancelled) return;
if (library.nodes.length) setNodeLibrary(library.nodes);
setAllowsCycles(library.allows_cycles);
})
.catch(() => {
if (!cancelled) setNodeLibrary(FALLBACK_WORKFLOW_LIBRARY);
});
return () => {
cancelled = true;
};
}, [reload, settings]);
useEffect(() => {
if (!draft?.id) {
setRevisions([]);
return;
}
let cancelled = false;
void listWorkflowRevisions(settings, draft.id)
.then((items) => {
if (!cancelled) setRevisions(items);
})
.catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
});
return () => {
cancelled = true;
};
}, [draft?.id, draft?.currentRevision, settings]);
const discardDraft = useCallback(() => {
if (!savedDraft) {
setDraft(null);
return;
}
const next = structuredClone(savedDraft);
setDraft(next);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
setError("");
setSuccess("");
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canWrite || !draft.name.trim()) {
setError(
!draft?.name.trim()
? "Workflow name is required."
: "You cannot save this workflow."
);
return false;
}
setWorking(true);
setError("");
setSuccess("");
try {
const saved = draft.id && draft.currentRevision
? await updateWorkflowDefinition(settings, draft.id, {
...workflowPayload(draft),
expected_revision: draft.currentRevision
})
: await createWorkflowDefinition(settings, workflowPayload(draft));
applyDefinition(saved);
setDefinitions((current) => [
saved,
...current.filter((item) => item.id !== saved.id)
]);
setSuccess(`Saved revision ${saved.current_revision}.`);
return true;
} catch (saveError) {
setError(apiErrorMessage(saveError));
return false;
} finally {
setWorking(false);
}
}, [applyDefinition, canWrite, draft, settings]);
useUnsavedDraftGuard({
dirty,
title: "Unsaved workflow",
message: "Save or discard the workflow changes before leaving this workspace.",
onSave: saveDraft,
onDiscard: discardDraft
});
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty]);
const selectDefinition = (definition: WorkflowDefinition) => {
requestNavigation(() => {
applyDefinition(definition);
setError("");
setSuccess("");
});
};
const createNew = () => {
requestNavigation(() => {
const next = sampleWorkflowDraft();
setDraft(next);
setSavedDraft(null);
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
setError("");
setSuccess("");
});
};
const validate = async () => {
if (!displayedGraph) return;
setWorking(true);
setError("");
try {
const result = await validateWorkflowDefinition(settings, displayedGraph);
setDiagnostics(result.diagnostics);
setSuccess(result.valid ? "Workflow definition is valid." : "");
} catch (validationError) {
setError(apiErrorMessage(validationError));
} finally {
setWorking(false);
}
};
const activate = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
setError("");
try {
const saved = await activateWorkflowDefinition(
settings,
draft.id,
draft.currentRevision
);
applyDefinition(saved);
setDefinitions((current) => current.map((item) =>
item.id === saved.id ? saved : item
));
setSuccess(`Activated revision ${saved.active_revision}.`);
} catch (activationError) {
setError(apiErrorMessage(activationError));
} finally {
setWorking(false);
}
};
const archiveDefinition = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
setError("");
try {
const saved = await archiveWorkflowDefinition(settings, draft.id);
applyDefinition(saved);
setDefinitions((current) => current.map((item) =>
item.id === saved.id ? saved : item
));
setSuccess("Workflow archived.");
} catch (archiveError) {
setError(apiErrorMessage(archiveError));
} finally {
setWorking(false);
}
};
const removeDefinition = async () => {
if (!draft?.id) return;
setWorking(true);
setError("");
try {
await deleteWorkflowDefinition(settings, draft.id);
setDeleteOpen(false);
await reload();
} catch (deleteError) {
setError(apiErrorMessage(deleteError));
} finally {
setWorking(false);
}
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (readOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
setDiagnostics([]);
setSuccess("");
};
const removeNode = (nodeId: string) => {
if (!draft || readOnly) return;
updateGraph({
...draft.graph,
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
edges: draft.graph.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
)
});
setSelectedNodeId(null);
};
return (
<main className="workflow-page">
<div className="workflow-shell">
<aside className="workflow-definition-panel">
<div className="workflow-panel-toolbar">
<strong>Workflows</strong>
<span className="workflow-toolbar-actions">
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
variant="ghost"
onClick={() => void reload(draft?.id)}
disabled={loading || working}
/>
<IconButton
label="New workflow"
icon={<Plus size={17} />}
variant="primary"
onClick={createNew}
disabled={!canWrite}
/>
</span>
</div>
<div className="workflow-definition-search">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows"
aria-label="Search workflows"
/>
</div>
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
<div className="workflow-definition-list">
{visibleDefinitions.map((definition) => (
<button
key={definition.id}
type="button"
className={definition.id === draft?.id ? "is-selected" : ""}
onClick={() => selectDefinition(definition)}
>
<span>
<strong>{definition.name}</strong>
<small>
{definition.key} · revision {definition.current_revision}
</small>
</span>
<StatusBadge
status={definition.status}
label={definition.status}
/>
</button>
))}
{!visibleDefinitions.length ? (
<div className="workflow-definition-empty">
No workflow definitions
</div>
) : null}
</div>
</LoadingFrame>
</aside>
<section className="workflow-workspace">
{draft && displayedGraph ? (
<>
<div className="workflow-workspace-toolbar">
<span className="workflow-identity-fields">
<input
className="workflow-name-input"
value={draft.name}
onChange={(event) => setDraft({
...draft,
name: event.target.value
})}
disabled={readOnly}
aria-label="Workflow name"
/>
<input
className="workflow-description-input"
value={draft.description}
onChange={(event) => setDraft({
...draft,
description: event.target.value
})}
disabled={readOnly}
placeholder="Description"
aria-label="Workflow description"
/>
</span>
<span className="workflow-command-bar">
{draft.id ? (
<select
className="workflow-revision-select"
value={
historicalRevision?.revision
?? draft.currentRevision
?? 1
}
onChange={(event) => {
const revision = Number(event.target.value);
const historical = revisions.find(
(item) => item.revision === revision
) ?? null;
setHistoricalRevision(
revision === draft.currentRevision
? null
: historical
);
setSelectedNodeId(
(historical?.graph ?? draft.graph).nodes[0]?.id ?? null
);
setDiagnostics([]);
}}
aria-label="Workflow revision"
>
{revisions.map((revision) => (
<option key={revision.id} value={revision.revision}>
Revision {revision.revision}
{revision.revision === draft.activeRevision
? " (active)"
: ""}
</option>
))}
</select>
) : null}
{historicalRevision ? (
<Button
onClick={() => {
setDraft({
...draft,
graph: structuredClone(historicalRevision.graph)
});
setHistoricalRevision(null);
setDiagnostics([]);
}}
disabled={!canWrite}
>
<RotateCcw size={16} /> Restore
</Button>
) : null}
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
{draft.id && draft.status !== "archived" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canWrite || dirty || working || readOnly}
>
<Archive size={16} /> Archive
</Button>
) : null}
{draft.id && draft.status !== "active" ? (
<Button
variant="primary"
onClick={() => void activate()}
disabled={!canWrite || dirty || working || readOnly}
>
<CheckCircle2 size={16} /> Activate
</Button>
) : null}
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canWrite || !dirty || working || readOnly}
>
<Save size={16} /> Save
</Button>
{draft.id ? (
<IconButton
label="Delete workflow"
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canWrite || working}
/>
) : null}
</span>
</div>
<div className="workflow-alerts">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
{success ? (
<DismissibleAlert tone="success" resetKey={success}>
{success}
</DismissibleAlert>
) : null}
</div>
<div className="workflow-editor">
<aside className="workflow-palette">
<div className="workflow-panel-heading">
<span>
<strong>Node library</strong>
<small>Drag nodes onto the canvas</small>
</span>
</div>
<div className="workflow-palette-items">
{paletteGroups.map((group) => (
<section key={group.category} className="workflow-palette-group">
<h3>{group.label}</h3>
{group.nodes.map((nodeType) => (
<button
key={nodeType.type}
type="button"
draggable={!readOnly}
disabled={readOnly}
onDragStart={(event) =>
startPaletteDrag(event, nodeType.type)
}
title={nodeType.description}
>
<GitFork size={16} />
<span>{nodeType.label}</span>
<Plus size={13} className="workflow-palette-add" />
</button>
))}
</section>
))}
</div>
</aside>
<div className="workflow-editor-surface">
<ReactFlowProvider>
<WorkflowCanvas
graph={displayedGraph}
diagnostics={diagnostics}
nodeLibrary={nodeLibrary}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowsCycles={allowsCycles}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
/>
</ReactFlowProvider>
</div>
<div className="workflow-inspector-column">
<WorkflowInspector
node={selectedNode}
nodeLibrary={nodeLibrary}
readOnly={readOnly}
onChange={(node) => {
if (!draft || readOnly) return;
updateGraph(updateWorkflowGraphNode(draft.graph, node));
}}
onDelete={removeNode}
/>
{diagnostics.length ? (
<div className="workflow-diagnostics">
<div className="workflow-panel-heading">
<strong>Diagnostics</strong>
<StatusBadge
status={
diagnostics.some((item) => item.severity === "error")
? "error"
: "warning"
}
label={String(diagnostics.length)}
/>
</div>
<div>
{diagnostics.map((item, index) => (
<button
key={`${item.code}-${item.node_id ?? "graph"}-${index}`}
type="button"
onClick={() => {
if (item.node_id) setSelectedNodeId(item.node_id);
}}
>
<strong>{item.message}</strong>
<small>{item.code}</small>
</button>
))}
</div>
</div>
) : null}
</div>
</div>
{working ? (
<div className="workflow-working-indicator" role="status">
Working...
</div>
) : null}
</>
) : (
<div className="workflow-workspace-empty">
<GitFork size={34} />
<strong>No workflow selected</strong>
{canWrite ? (
<Button variant="primary" onClick={createNew}>
<Plus size={16} /> New workflow
</Button>
) : null}
</div>
)}
</section>
</div>
<ConfirmDialog
open={deleteOpen}
title="Delete workflow"
message={`Delete ${draft?.name ?? "this workflow"} and all of its definition revisions?`}
confirmLabel="Delete"
tone="danger"
busy={working}
onCancel={() => setDeleteOpen(false)}
onConfirm={() => void removeDefinition()}
/>
</main>
);
}
function startPaletteDrag(
event: DragEvent<HTMLButtonElement>,
type: string
) {
event.dataTransfer.setData("application/x-govoplan-workflow-node", type);
event.dataTransfer.effectAllowed = "copy";
}
function apiErrorMessage(error: unknown): string {
if (!isApiError(error)) {
return error instanceof Error ? error.message : "The request failed.";
}
try {
const parsed = JSON.parse(error.body) as {
detail?: string | { message?: string };
};
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail?.message) return parsed.detail.message;
} catch {
// Fall through to the API error message.
}
return error.message;
}