Files
govoplan-workflow/webui/src/features/workflow/WorkflowPage.tsx
T

1572 lines
53 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent
} from "react";
import {
Archive,
CheckCircle2,
CopyPlus,
Download,
GitFork,
ListChecks,
Plus,
RotateCcw,
Save,
Settings2,
Trash2,
Upload
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "react-router";
import { ActionToolbar,
Button,
ConfirmDialog,
ContentGrid,
ContentSection,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
DefinitionPalette,
DefinitionPaletteGroup,
DefinitionPaletteItem,
FilterBar,
FloatingStatus,
FormField,
IconButton,
LoadingFrame,
ReferenceSelect,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
useEffectiveView,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
activateWorkflowDefinition,
archiveWorkflowDefinition,
compareWorkflowDefinitionToStandard,
compileWorkflowBpmn,
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
reconcileWorkflowStandards,
renderWorkflowBpmn,
resetWorkflowDefinitionToStandard,
updateWorkflowDefinition,
validateWorkflowDefinition,
workflowScopeReferenceProvider,
type WorkflowDefinition,
type WorkflowDiagnostic,
type WorkflowGraphEdge,
type WorkflowNodeType,
type WorkflowRevision,
type WorkflowStandardDiff
} from "../../api/workflow";
import WorkflowCanvas, {
updateWorkflowGraphNode
} from "./WorkflowCanvas";
import WorkflowInspector from "./WorkflowInspector";
import WorkflowRunsDialog from "./WorkflowRunsDialog";
import {
FALLBACK_WORKFLOW_LIBRARY,
draftFromDefinition,
newWorkflowNode,
sampleWorkflowDraft,
workflowFingerprint,
workflowPayload,
type WorkflowDraft
} from "./model";
const CATEGORY_ORDER = [
"bpmn_event",
"bpmn_activity",
"bpmn_gateway",
"bpmn_data",
"bpmn_collaboration",
"bpmn_artifact"
];
export default function WorkflowPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const { requestDiscard, 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);
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 [selectedEdgeId, setSelectedEdgeId] = 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 [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [compareOpen, setCompareOpen] = useState(false);
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
const canEdit = canWrite && (
!draft?.id || draft.governance?.actions.edit?.allowed !== false
);
const canActivate = Boolean(
canWrite
&& (draft?.standard?.kind === "baseline" || canEdit)
);
const canReuse = Boolean(
draft?.id
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const canStart = Boolean(
draft?.id
&& (hasScope(auth, "workflow:instance:start")
|| hasScope(auth, "workflow:instance:admin"))
&& draft.governance?.actions.start?.allowed !== false
);
const canTransition = hasScope(auth, "workflow:instance:transition")
|| hasScope(auth, "workflow:instance:admin");
const selectedDefinition = useMemo(
() => definitions.find((item) => item.id === draft?.id) ?? null,
[definitions, draft?.id]
);
const dirty = Boolean(draft)
&& 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;
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);
setSelectedEdgeId(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(requestedDefinitionId);
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, requestedDefinitionId, settings]);
useEffect(() => {
if (
requestedRunId
&& draft?.id
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
) {
setRunsOpen(true);
}
}, [draft?.id, requestedDefinitionId, requestedRunId]);
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);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canEdit || !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, canEdit, 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);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
});
};
const validate = async () => {
if (!displayedGraph) return;
setWorking(true);
setError("");
setSuccess("");
try {
const result = await validateWorkflowDefinition(settings, displayedGraph);
setDiagnostics(result.diagnostics);
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
} catch (validationError) {
setError(apiErrorMessage(validationError));
} finally {
setWorking(false);
}
};
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);
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 reconcileStandards = async () => {
setWorking(true);
setError("");
setSuccess("");
try {
const result = await reconcileWorkflowStandards(settings);
await reload(draft?.id);
setSuccess(
`Module standards: ${result.created} installed, ${result.updated} updated, ${result.blocked} blocked.`
);
} catch (reconcileError) {
setError(apiErrorMessage(reconcileError));
} finally {
setWorking(false);
}
};
const resetToStandard = async () => {
if (!draft?.id) return;
setWorking(true);
setError("");
try {
const baseline = await resetWorkflowDefinitionToStandard(
settings,
draft.id
);
setResetOpen(false);
await reload(baseline.id);
setSuccess("The local override was archived and the module standard restored.");
} catch (resetError) {
setError(apiErrorMessage(resetError));
} finally {
setWorking(false);
}
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (graphReadOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
setDiagnostics([]);
setSuccess("");
};
const removeNode = (nodeId: string) => {
if (!draft || graphReadOnly) 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);
};
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);
};
const addNodeFromPalette = (nodeType: string) => {
if (!draft || graphReadOnly) return;
const index = draft.graph.nodes.length;
const node = newWorkflowNode(
nodeType,
{
x: 80 + (index % 4) * 220,
y: 80 + Math.floor(index / 4) * 150
},
nodeLibrary
);
updateGraph({ ...draft.graph, nodes: [...draft.graph.nodes, node] });
setSelectedNodeId(node.id);
};
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="workflow-page" label="Workflow workspace">
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Workflows"
contentLabel="Workflow editor"
contentClassName="workflow-workspace"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(draft?.id), loading: loading || working }}
contextActions={<>
<strong>Workflows</strong>
{hasScope(auth, "workflow:instance:admin") ? (
<IconButton
label="Reconcile module standards"
icon={<GitFork size={16} />}
variant="ghost"
onClick={() => void reconcileStandards()}
disabled={loading || working}
/>
) : null}
</>}
helpAction={<DocumentationHelpLink
reference={{ topicId: "workflow.editor", documentationType: "user" }}
label="Open Workflow documentation"
/>}
createAction={<IconButton
label="New workflow"
icon={<Plus size={17} />}
variant="primary"
onClick={createNew}
disabled={!canWrite}
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
/>}
/>
<FilterBar surface="panel">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows"
aria-label="Search workflows"
/>
</FilterBar>
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
<SelectionList variant="navigation" label="Workflows">
{visibleDefinitions.map((definition) => (
<SelectionListItem
key={definition.id}
selected={definition.id === draft?.id}
onClick={() => selectDefinition(definition)}
>
<SelectionListItemContent
title={definition.name}
description={[
definition.key,
`revision ${definition.current_revision}`,
definition.governance.scope_type,
definition.governance.definition_kind,
definition.standard
? `${definition.standard.kind === "baseline" ? "Standard" : "Override"} · ${definition.standard.origin_module_id}${definition.standard.update_available ? " · update available" : ""}`
: ""
].filter(Boolean).join(" · ")}
/>
<StatusBadge
status={definition.status}
label={definition.status}
/>
</SelectionListItem>
))}
{!visibleDefinitions.length ? (
<StatePanel size="compact" description="No workflow definitions" />
) : null}
</SelectionList>
</LoadingFrame>
</>}
>
{draft && displayedGraph ? (
<>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state={working ? "saving" : dirty && !draft.name.trim() ? "invalid" : dirty ? "dirty" : "clean"}
className="workflow-workspace-toolbar"
contextActions={<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>}
primaryActions={<span className="workflow-command-bar">
{draft.id ? (
<select
className="workflow-revision-select"
value={
historicalRevision?.revision
?? draft.currentRevision
?? 1
}
onChange={(event) => selectRevision(
Number(event.target.value)
)}
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}
{draft.standard?.kind === "override"
&& draft.standard.update_available ? (
<Button
onClick={() => setCompareOpen(true)}
disabled={working}
>
<GitFork size={16} /> Compare update
</Button>
) : null}
{draft.standard?.kind === "override"
&& draft.standard.reset_available ? (
<Button
onClick={() => setResetOpen(true)}
disabled={working || dirty}
>
<RotateCcw size={16} /> Reset
</Button>
) : null}
{historicalRevision ? (
<Button
onClick={() => {
setDraft({
...draft,
graph: structuredClone(historicalRevision.graph),
executionMode: historicalRevision.execution_mode,
viewId: historicalRevision.view_id ?? "",
viewRevisionId:
historicalRevision.view_revision_id ?? ""
});
setHistoricalRevision(null);
setDiagnostics([]);
}}
disabled={!canEdit}
>
<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>
{draft.id ? (
<Button
onClick={() => setRunsOpen(true)}
disabled={dirty || historicalRevision !== null}
>
<ListChecks size={16} /> Runs
</Button>
) : null}
<IconButton
label="Definition settings"
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => setDefinitionSettingsOpen(true)}
/>
{draft.id ? (
<IconButton
label="Reuse as scoped copy"
icon={<CopyPlus size={16} />}
variant="ghost"
onClick={() => setDeriveOpen(true)}
disabled={!canReuse}
/>
) : null}
{draft.id
&& draft.status !== "archived"
&& draft.standard?.kind !== "baseline" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canEdit || dirty || working || readOnly}
>
<Archive size={16} /> Archive
</Button>
) : null}
{draft.id && (
draft.status !== "active"
|| draft.activeRevision !== draft.currentRevision
) ? (
<Button
variant="primary"
onClick={() => void activate()}
disabled={
!canActivate
|| dirty
|| working
|| historicalRevision !== null
|| draft.definitionKind === "template"
}
disabledReason={
draft.definitionKind === "template"
? "Templates cannot be activated or started."
: undefined
}
>
<CheckCircle2 size={16} /> Activate
</Button>
) : null}
</span>}
destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? (
<IconButton
label="Delete workflow"
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canEdit || working}
/>
) : undefined}
discardAction={{
label: <><RotateCcw size={16} /> Discard</>,
onClick: () => requestDiscard(() => void reload(draft.id))
}}
saveAction={{
label: <><Save size={16} /> Save</>,
onClick: () => void saveDraft(),
disabled: !canEdit || readOnly
}}
/>
<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">
<DefinitionPalette label="Node library" description="Drag nodes onto the canvas">
{paletteGroups.map((group) => (
<DefinitionPaletteGroup key={group.category} label={group.label}>
{group.nodes.map((nodeType) => (
<DefinitionPaletteItem
key={nodeType.type}
icon={<GitFork size={16} />}
label={nodeType.label}
draggable={!graphReadOnly}
disabled={graphReadOnly}
onDragStart={(event) =>
startPaletteDrag(event, nodeType.type)
}
onClick={() => addNodeFromPalette(nodeType.type)}
title={nodeType.description}
/>
))}
</DefinitionPaletteGroup>
))}
</DefinitionPalette>
<div className="definition-editor-surface 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">
<ActionToolbar surface="section-header" className="workflow-panel-heading">
<strong>Diagnostics</strong>
<StatusBadge
status={
diagnostics.some((item) => item.severity === "error")
? "error"
: "warning"
}
label={String(diagnostics.length)}
/>
</ActionToolbar>
<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 ? <FloatingStatus>Working...</FloatingStatus> : null}
</>
) : (
<StatePanel
size="fill"
icon={<GitFork size={34} />}
title="No workflow selected"
actions={canWrite ? (
<Button variant="primary" onClick={createNew}>
<Plus size={16} /> New workflow
</Button>
) : undefined}
/>
)}
</WorkspaceLayout>
<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()}
/>
<ConfirmDialog
open={resetOpen}
title="Reset to module standard"
message={`Archive ${draft?.name ?? "this override"} and restore the latest module standard? Historical revisions and running instances remain unchanged.`}
confirmLabel="Reset"
busy={working}
onCancel={() => setResetOpen(false)}
onConfirm={() => void resetToStandard()}
/>
<WorkflowStandardComparisonDialog
open={compareOpen}
settings={settings}
override={selectedDefinition}
onClose={() => setCompareOpen(false)}
/>
<WorkflowDefinitionSettingsDialog
open={definitionSettingsOpen}
settings={settings}
draft={draft}
editable={canEdit}
onChange={(patch) => setDraft((current) => current ? { ...current, ...patch } : current)}
onClose={() => setDefinitionSettingsOpen(false)}
/>
<DeriveWorkflowDialog
open={deriveOpen}
settings={settings}
definition={draft?.id ? {
id: draft.id,
name: draft.name,
revision: draft.currentRevision ?? 1
} : null}
onClose={() => setDeriveOpen(false)}
onDerived={(definition) => {
applyDefinition(definition);
setDefinitions((current) => [
definition,
...current.filter((item) => item.id !== definition.id)
]);
setDeriveOpen(false);
setSuccess("Created a pinned scoped copy.");
}}
/>
<WorkflowRunsDialog
open={runsOpen}
settings={settings}
definition={selectedDefinition}
initialInstanceId={requestedRunId}
canStart={canStart}
canTransition={canTransition}
onClose={() => {
setRunsOpen(false);
if (requestedRunId) {
const next = new URLSearchParams(searchParams);
next.delete("run");
setSearchParams(next, { replace: true });
}
}}
/>
</WorkspaceFrame>
);
}
function WorkflowStandardComparisonDialog({
open,
settings,
override,
onClose
}: {
open: boolean;
settings: ApiSettings;
override: WorkflowDefinition | null;
onClose: () => void;
}) {
const [comparison, setComparison] = useState<WorkflowStandardDiff | null>(null);
const [error, setError] = useState("");
useEffect(() => {
if (!open || !override) return;
let cancelled = false;
setError("");
setComparison(null);
void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => {
if (cancelled) return;
setComparison(result);
}).catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
});
return () => {
cancelled = true;
};
}, [open, override, settings]);
return (
<Dialog
open={open}
title="Compare module standard update"
className="workflow-standard-comparison-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
{!error && !comparison ? (
<LoadingFrame loading label="Comparing workflow revisions...">
<div className="workflow-standard-comparison" />
</LoadingFrame>
) : null}
{comparison ? (
<div className="workflow-standard-comparison">
<div className="workflow-standard-comparison-summary">
<span>
Pinned revision <strong>{comparison.pinned_baseline_revision}</strong>
</span>
<span>
Local revision <strong>{comparison.local_revision}</strong>
</span>
<span>
Latest standard <strong>{comparison.latest_baseline_revision}</strong>
</span>
<StatusBadge
status={comparison.auto_mergeable ? "ready" : "warning"}
label={comparison.auto_mergeable
? "No semantic conflicts"
: `${comparison.conflict_count} conflicts`}
/>
</div>
<div className="workflow-standard-diff-list">
{comparison.items.map((item) => (
<section
className={`workflow-standard-diff-item state-${item.state}`}
key={`${item.resource_type}:${item.resource_id}`}
>
<header>
<span>
<strong>{item.resource_id}</strong>
<small>{item.resource_type} · {item.changed_fields.join(", ")}</small>
</span>
<StatusBadge
status={item.state === "conflict"
? "warning"
: item.state === "upstream_only"
? "queued"
: "active"}
label={standardDiffLabel(item.state)}
/>
</header>
<p>{standardDiffAction(item.recommended_action)}</p>
</section>
))}
{!comparison.items.length ? (
<p className="workflow-standard-diff-empty">
The local override and latest standard contain no semantic changes.
</p>
) : null}
</div>
</div>
) : null}
</Dialog>
);
}
function standardDiffLabel(state: string): string {
return ({
local_only: "Local change",
upstream_only: "Standard update",
same_change: "Same change",
conflict: "Conflict",
unchanged: "Unchanged"
} as Record<string, string>)[state] ?? state;
}
function standardDiffAction(action: string): string {
return ({
keep_local: "The local change can be retained automatically.",
adopt_upstream: "The standard update can be adopted automatically.",
either: "Both revisions made the same semantic change.",
manual_resolution: "Local and upstream changes overlap and need a decision.",
none: "No action is required."
} as Record<string, string>)[action] ?? action;
}
function WorkflowDefinitionSettingsDialog({
open,
settings,
draft,
editable,
onChange,
onClose
}: {
open: boolean;
settings: ApiSettings;
draft: WorkflowDraft | null;
editable: boolean;
onChange: (patch: Partial<WorkflowDraft>) => void;
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),
[
referenceScopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]
);
return (
<Dialog
open={open}
title="Definition settings"
className="workflow-definition-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{draft ? (
<div className="workflow-definition-fields">
<FormField
label="Scope"
help="The scope determines ownership, visibility, and the Policy inheritance path."
documentation={{ topicId: "workflow.editor", documentationType: "admin" }}
>
<select
value={draft.scopeType}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
scopeType: event.target.value as WorkflowDraft["scopeType"],
scopeId: ""
})}
>
<option value="system">System</option>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
{draft.scopeType === "user" || draft.scopeType === "group" ? (
<FormField
label={draft.scopeType === "user" ? "User" : "Group"}
help={
draft.scopeType === "user"
? "The stable account ID is stored; directory labels are presentation-only."
: "Only groups available in the active tenant can be selected."
}
>
<ReferenceSelect
value={draft.scopeId}
onChange={(value) => onChange({ scopeId: value })}
provider={scopeProvider}
aria-label={
draft.scopeType === "user"
? "Definition user"
: "Definition group"
}
placeholder={
draft.scopeType === "user"
? "Select a user"
: "Select a group"
}
disabled={!editable || Boolean(draft.id)}
required
/>
</FormField>
) : null}
<FormField
label="Definition kind"
help="Templates can be reused or derived, but cannot be activated or started."
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
>
<select
value={draft.definitionKind}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
definitionKind: event.target.value as WorkflowDraft["definitionKind"],
status: "draft"
})}
>
<option value="flow">Complete flow</option>
<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."
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
>
<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."
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
>
<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>
<ContentGrid columns={2} gap="default" collapseAt="narrow">
<ToggleSwitch
label="Visible to lower scopes"
checked={draft.inheritToLowerScopes}
disabled={!editable}
onChange={(value) => onChange({ inheritToLowerScopes: value })}
/>
<ToggleSwitch
label="Allow starts"
checked={draft.allowStart}
disabled={!editable}
onChange={(value) => onChange({ allowStart: value })}
/>
<ToggleSwitch
label="Allow reuse"
checked={draft.allowReuse}
disabled={!editable}
onChange={(value) => onChange({ allowReuse: value })}
/>
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable || draft.executionMode === "guided"}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</ContentGrid>
{draft.governance?.automation_runtime_reason ? (
<DismissibleAlert
tone="warning"
resetKey={draft.governance.automation_runtime_reason}
>
{draft.governance.automation_runtime_reason}
</DismissibleAlert>
) : null}
{draft.governance?.derived_from_definition_id ? (
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
<strong>Derived from</strong>
<span>
{draft.governance.derived_from_definition_id}
{" · revision "}
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
</ContentSection>
) : null}
{provenance.length ? (
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
<strong>Effective Policy path</strong>
{provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
{String(item.label ?? item.path ?? item.scope_type)}
</span>
))}
{!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small>
) : null}
</ContentSection>
) : null}
</div>
) : null}
</Dialog>
);
}
function DeriveWorkflowDialog({
open,
settings,
definition,
onClose,
onDerived
}: {
open: boolean;
settings: ApiSettings;
definition: { id: string; name: string; revision: number } | null;
onClose: () => void;
onDerived: (definition: WorkflowDefinition) => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<"flow" | "template">("flow");
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
const [scopeId, setScopeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const scopeProvider = useMemo(
() =>
workflowScopeReferenceProvider(
settings,
scopeType === "group" ? "group" : "user"
),
[
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]
);
useEffect(() => {
if (!open) return;
setName(`${definition?.name ?? "Workflow"} copy`);
setKind("flow");
setScopeType("tenant");
setScopeId("");
setError("");
}, [open, definition?.id]);
const derive = async () => {
if (!definition || !name.trim()) return;
setBusy(true);
setError("");
try {
onDerived(await deriveWorkflowDefinition(settings, definition.id, {
name: name.trim(),
source_revision: definition.revision,
metadata: {},
scope_type: scopeType,
scope_id: scopeId.trim() || null,
definition_kind: kind,
inherit_to_lower_scopes: false,
allow_start: true,
allow_reuse: false,
allow_automation: false
}));
} catch (deriveError) {
setError(apiErrorMessage(deriveError));
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
title="Reuse as scoped copy"
className="workflow-definition-dialog"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void derive()}
disabled={
busy
|| !definition
|| !name.trim()
|| (scopeType !== "tenant" && !scopeId)
}
>
<CopyPlus size={16} /> Create copy
</Button>
</>
)}
>
<div className="workflow-definition-fields">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormField label="Name">
<input value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Target scope">
<select
value={scopeType}
onChange={(event) => {
setScopeType(event.target.value as typeof scopeType);
setScopeId("");
}}
>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
{scopeType !== "tenant" ? (
<FormField label={scopeType === "user" ? "User" : "Group"}>
<ReferenceSelect
value={scopeId}
onChange={(value) => setScopeId(value)}
provider={scopeProvider}
aria-label={
scopeType === "user" ? "Copy target user" : "Copy target group"
}
placeholder={
scopeType === "user" ? "Select a user" : "Select a group"
}
disabled={busy}
required
/>
</FormField>
) : null}
<FormField label="Copy kind">
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<small>
The source graph revision, node-library version, content hash, and
effective ancestor limits are retained as provenance.
</small>
</div>
</Dialog>
);
}
function startPaletteDrag(
event: DragEvent<HTMLButtonElement>,
type: string
) {
event.dataTransfer.setData("application/x-govoplan-workflow-node", type);
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.";
}
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;
}