Implement native BPMN workflows and guided modes

This commit is contained in:
2026-07-31 02:48:57 +02:00
parent c505e81006
commit f4974b4949
40 changed files with 8203 additions and 489 deletions
+329 -116
View File
@@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent
} from "react";
@@ -9,6 +10,7 @@ import {
Archive,
CheckCircle2,
CopyPlus,
Download,
GitFork,
ListChecks,
Plus,
@@ -16,9 +18,11 @@ import {
RotateCcw,
Save,
Settings2,
Trash2
Trash2,
Upload
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "react-router";
import {
Button,
ConfirmDialog,
@@ -34,23 +38,27 @@ import {
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
useEffectiveView,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
activateWorkflowDefinition,
archiveWorkflowDefinition,
compileWorkflowBpmn,
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
renderWorkflowBpmn,
updateWorkflowDefinition,
validateWorkflowDefinition,
workflowScopeReferenceProvider,
type WorkflowDefinition,
type WorkflowDiagnostic,
type WorkflowGraphEdge,
type WorkflowNodeType,
type WorkflowRevision
} from "../../api/workflow";
@@ -69,12 +77,12 @@ import {
} from "./model";
const CATEGORY_ORDER = [
"trigger",
"activity",
"decision",
"wait",
"integration",
"outcome"
"bpmn_event",
"bpmn_activity",
"bpmn_gateway",
"bpmn_data",
"bpmn_collaboration",
"bpmn_artifact"
];
export default function WorkflowPage({
@@ -85,6 +93,9 @@ export default function WorkflowPage({
auth: AuthInfo;
}) {
const { requestNavigation } = useUnsavedChanges();
const [searchParams, setSearchParams] = useSearchParams();
const requestedDefinitionId = searchParams.get("definition");
const requestedRunId = searchParams.get("run");
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
const [savedDraft, setSavedDraft] = useState<WorkflowDraft | null>(null);
@@ -96,6 +107,7 @@ export default function WorkflowPage({
);
const [allowsCycles, setAllowsCycles] = useState(true);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
@@ -106,6 +118,7 @@ export default function WorkflowPage({
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
@@ -133,10 +146,15 @@ export default function WorkflowPage({
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canEdit || historicalRevision !== null;
const graphReadOnly = readOnly;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
);
const selectedEdge = useMemo(
() => displayedGraph?.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
[displayedGraph, selectedEdgeId]
);
const visibleDefinitions = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return definitions;
@@ -162,6 +180,7 @@ export default function WorkflowPage({
setSavedDraft(structuredClone(next));
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
}, []);
@@ -189,7 +208,7 @@ export default function WorkflowPage({
}, [applyDefinition, settings]);
useEffect(() => {
void reload();
void reload(requestedDefinitionId);
let cancelled = false;
void listWorkflowNodeTypes(settings)
.then((library) => {
@@ -203,7 +222,17 @@ export default function WorkflowPage({
return () => {
cancelled = true;
};
}, [reload, settings]);
}, [reload, requestedDefinitionId, settings]);
useEffect(() => {
if (
requestedRunId
&& draft?.id
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
) {
setRunsOpen(true);
}
}, [draft?.id, requestedDefinitionId, requestedRunId]);
useEffect(() => {
if (!draft?.id) {
@@ -232,6 +261,7 @@ export default function WorkflowPage({
setDraft(next);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
@@ -305,6 +335,7 @@ export default function WorkflowPage({
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
@@ -315,10 +346,11 @@ export default function WorkflowPage({
if (!displayedGraph) return;
setWorking(true);
setError("");
setSuccess("");
try {
const result = await validateWorkflowDefinition(settings, displayedGraph);
setDiagnostics(result.diagnostics);
setSuccess(result.valid ? "Workflow definition is valid." : "");
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
} catch (validationError) {
setError(apiErrorMessage(validationError));
} finally {
@@ -326,6 +358,73 @@ export default function WorkflowPage({
}
};
const importBpmnFile = async (file: File | null) => {
if (!file || readOnly) return;
setWorking(true);
setError("");
setSuccess("");
try {
const xml = await file.text();
const imported = await compileWorkflowBpmn(settings, {
xml,
adapter_id: "govoplan.native.bpmn",
adapter_version: "1.0.0"
});
setDraft((current) => current ? {
...current,
graph: imported.graph
} : current);
setSelectedNodeId(imported.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setSuccess("Imported BPMN XML into the native graph.");
} catch (fileError) {
setError(apiErrorMessage(fileError));
} finally {
setWorking(false);
}
};
const exportBpmnFile = async () => {
if (!displayedGraph) return;
setWorking(true);
setError("");
try {
const rendered = await renderWorkflowBpmn(settings, {
graph: displayedGraph,
name: draft?.name ?? "Workflow"
});
const url = URL.createObjectURL(
new Blob([rendered.xml], { type: "application/xml" })
);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName(draft?.name || "workflow")}.bpmn`;
link.click();
URL.revokeObjectURL(url);
} catch (exportError) {
setError(apiErrorMessage(exportError));
} finally {
setWorking(false);
}
};
const selectRevision = (revisionNumber: number) => {
if (!draft?.id) return;
setDiagnostics([]);
setSelectedEdgeId(null);
if (revisionNumber === draft.currentRevision) {
setHistoricalRevision(null);
setSelectedNodeId(draft.graph.nodes[0]?.id ?? null);
return;
}
const historical = revisions.find(
(item) => item.revision === revisionNumber
) ?? null;
setHistoricalRevision(historical);
setSelectedNodeId(historical?.graph.nodes[0]?.id ?? null);
};
const activate = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
@@ -382,14 +481,14 @@ export default function WorkflowPage({
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (readOnly) return;
if (graphReadOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
setDiagnostics([]);
setSuccess("");
};
const removeNode = (nodeId: string) => {
if (!draft || readOnly) return;
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
@@ -400,6 +499,25 @@ export default function WorkflowPage({
setSelectedNodeId(null);
};
const updateEdge = (updatedEdge: WorkflowGraphEdge) => {
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
edges: draft.graph.edges.map((edge) =>
edge.id === updatedEdge.id ? updatedEdge : edge
)
});
};
const removeEdge = (edgeId: string) => {
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
edges: draft.graph.edges.filter((edge) => edge.id !== edgeId)
});
setSelectedEdgeId(null);
};
return (
<main className="workflow-page">
<div className="workflow-shell">
@@ -501,21 +619,9 @@ export default function WorkflowPage({
?? 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([]);
}}
onChange={(event) => selectRevision(
Number(event.target.value)
)}
aria-label="Workflow revision"
>
{revisions.map((revision) => (
@@ -533,7 +639,11 @@ export default function WorkflowPage({
onClick={() => {
setDraft({
...draft,
graph: structuredClone(historicalRevision.graph)
graph: structuredClone(historicalRevision.graph),
executionMode: historicalRevision.execution_mode,
viewId: historicalRevision.view_id ?? "",
viewRevisionId:
historicalRevision.view_revision_id ?? ""
});
setHistoricalRevision(null);
setDiagnostics([]);
@@ -543,6 +653,30 @@ export default function WorkflowPage({
<RotateCcw size={16} /> Restore
</Button>
) : null}
<IconButton
label="Import BPMN XML"
icon={<Upload size={16} />}
variant="ghost"
onClick={() => bpmnFileInputRef.current?.click()}
disabled={readOnly || working}
/>
<input
ref={bpmnFileInputRef}
className="workflow-bpmn-file-input"
type="file"
accept=".bpmn,.xml,application/xml,text/xml"
onChange={(event) => {
void importBpmnFile(event.target.files?.[0] ?? null);
event.target.value = "";
}}
/>
<IconButton
label="Export BPMN XML"
icon={<Download size={16} />}
variant="ghost"
onClick={() => void exportBpmnFile()}
disabled={!displayedGraph || working}
/>
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
@@ -600,7 +734,9 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canEdit || !dirty || working || readOnly}
disabled={
!canEdit || !dirty || working || readOnly
}
>
<Save size={16} /> Save
</Button>
@@ -628,93 +764,98 @@ export default function WorkflowPage({
) : 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>
<aside className="workflow-palette">
<div className="workflow-panel-heading">
<span>
<strong>Node library</strong>
<small>Drag nodes onto the canvas</small>
</span>
</div>
) : null}
<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={!graphReadOnly}
disabled={graphReadOnly}
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}
selectedEdgeId={selectedEdgeId}
readOnly={graphReadOnly}
allowsCycles={allowsCycles}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
onSelectEdge={setSelectedEdgeId}
/>
</ReactFlowProvider>
</div>
<div className="workflow-inspector-column">
<WorkflowInspector
node={selectedNode}
edge={selectedEdge}
nodeLibrary={nodeLibrary}
readOnly={graphReadOnly}
onChange={(node) => {
if (!draft || graphReadOnly) return;
updateGraph(updateWorkflowGraphNode(draft.graph, node));
}}
onDelete={removeNode}
onEdgeChange={updateEdge}
onEdgeDelete={removeEdge}
/>
{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>
</div>
{working ? (
<div className="workflow-working-indicator" role="status">
Working...
@@ -775,9 +916,17 @@ export default function WorkflowPage({
open={runsOpen}
settings={settings}
definition={selectedDefinition}
initialInstanceId={requestedRunId}
canStart={canStart}
canTransition={canTransition}
onClose={() => setRunsOpen(false)}
onClose={() => {
setRunsOpen(false);
if (requestedRunId) {
const next = new URLSearchParams(searchParams);
next.delete("run");
setSearchParams(next, { replace: true });
}
}}
/>
</main>
);
@@ -799,6 +948,7 @@ function WorkflowDefinitionSettingsDialog({
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
const effectiveView = useEffectiveView();
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
const scopeProvider = useMemo(
() => workflowScopeReferenceProvider(settings, referenceScopeType),
@@ -881,6 +1031,61 @@ function WorkflowDefinitionSettingsDialog({
<option value="template">Template</option>
</select>
</FormField>
<FormField
label="Execution mode"
help="Guided flows require a user; automated flows cannot contain human handoffs; hybrid flows can combine both."
>
<select
value={draft.executionMode}
disabled={!editable}
onChange={(event) => onChange({
executionMode:
event.target.value as WorkflowDraft["executionMode"],
allowAutomation:
event.target.value === "guided"
? false
: draft.allowAutomation
})}
>
<option value="guided">Guided UI workflow</option>
<option value="automated">Automated workflow</option>
<option value="hybrid">Hybrid workflow</option>
</select>
</FormField>
<FormField
label="Workflow View"
help="The selected immutable View revision is applied for active runs. Individual steps may narrow it further."
>
<select
value={draft.viewId}
disabled={!editable || !effectiveView}
onChange={(event) => {
const viewId = event.target.value;
const option = effectiveView?.availableViews.find(
(item) => item.id === viewId
);
onChange({
viewId,
viewRevisionId: option?.revisionId ?? ""
});
}}
>
<option value="">Use the current interface</option>
{draft.viewId
&& !effectiveView?.availableViews.some(
(item) => item.id === draft.viewId
) ? (
<option value={draft.viewId}>
Stored View (currently unavailable)
</option>
) : null}
{(effectiveView?.availableViews ?? []).map((view) => (
<option key={view.id} value={view.id}>
{view.name}
</option>
))}
</select>
</FormField>
<div className="workflow-definition-toggles">
<ToggleSwitch
label="Visible to lower scopes"
@@ -903,7 +1108,7 @@ function WorkflowDefinitionSettingsDialog({
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable}
disabled={!editable || draft.executionMode === "guided"}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
@@ -1094,6 +1299,14 @@ function startPaletteDrag(
event.dataTransfer.effectAllowed = "copy";
}
function fileName(value: string): string {
return value
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "workflow";
}
function apiErrorMessage(error: unknown): string {
if (!isApiError(error)) {
return error instanceof Error ? error.message : "The request failed.";