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

32
webui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/workflow-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

158
webui/src/api/workflow.ts Normal file
View File

@@ -0,0 +1,158 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
schema_version: 1;
nodes: WorkflowGraphNode[];
};
export type WorkflowDiagnostic = {
severity: "error" | "warning";
code: string;
message: string;
node_id?: string | null;
field?: string | null;
};
export type WorkflowNodeType = DefinitionGraphNodeType;
export type WorkflowRevision = {
id: string;
revision: number;
schema_version: number;
graph: WorkflowGraph;
content_hash: string;
library_id: string;
library_version: string;
created_by?: string | null;
created_at: string;
};
export type WorkflowDefinition = {
id: string;
tenant_id: string;
key: string;
name: string;
description?: string | null;
status: WorkflowStatus;
current_revision: number;
active_revision?: number | null;
metadata: Record<string, unknown>;
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
revision: WorkflowRevision;
};
export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
metadata: Record<string, unknown>;
};
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
return apiFetch(settings, "/api/v1/workflow/node-types");
}
export async function listWorkflowDefinitions(
settings: ApiSettings
): Promise<WorkflowDefinition[]> {
const response = await apiFetch<{ definitions: WorkflowDefinition[] }>(
settings,
"/api/v1/workflow/definitions"
);
return response.definitions;
}
export function createWorkflowDefinition(
settings: ApiSettings,
payload: WorkflowDefinitionPayload
): Promise<WorkflowDefinition> {
return apiFetch(settings, "/api/v1/workflow/definitions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: WorkflowDefinitionPayload & { expected_revision: number }
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{
method: "PUT",
body: JSON.stringify(payload)
}
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowRevision[]> {
const response = await apiFetch<{ revisions: WorkflowRevision[] }>(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions`
);
return response.revisions;
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
revision?: number
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/activate`,
{
method: "POST",
body: JSON.stringify({ revision })
}
);
}
export function archiveWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/archive`,
{ method: "POST" }
);
}
export function deleteWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<{ deleted: boolean; definition_id: string }> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{ method: "DELETE" }
);
}
export function validateWorkflowDefinition(
settings: ApiSettings,
graph: WorkflowGraph
): Promise<{ valid: boolean; diagnostics: WorkflowDiagnostic[] }> {
return apiFetch(settings, "/api/v1/workflow/definitions/validate", {
method: "POST",
body: JSON.stringify({ graph })
});
}

View File

@@ -0,0 +1,229 @@
import { useMemo, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
MiniMap,
ReactFlow,
type Connection,
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDiagnostic,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
import { newWorkflowNode } from "./model";
import WorkflowNode, { type WorkflowFlowNode } from "./WorkflowNode";
const nodeTypes = { workflow: WorkflowNode };
export default function WorkflowCanvas({
graph,
diagnostics,
nodeLibrary,
selectedNodeId,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | null
>(null);
const definitions = useMemo(
() => new Map(nodeLibrary.map((item) => [item.type, item])),
[nodeLibrary]
);
const errorNodeIds = useMemo(
() => new Set(
diagnostics
.filter((item) => item.severity === "error" && item.node_id)
.map((item) => item.node_id as string)
),
[diagnostics]
);
const nodes = useMemo<WorkflowFlowNode[]>(
() => graph.nodes.flatMap((node) => {
const definition = definitions.get(node.type);
if (!definition) return [];
return [{
id: node.id,
type: "workflow" as const,
position: node.position,
selected: node.id === selectedNodeId,
data: {
label: node.label,
workflowType: node.type,
definition,
hasError: errorNodeIds.has(node.id)
}
}];
}),
[definitions, errorNodeIds, graph.nodes, selectedNodeId]
);
const edges = useMemo<Edge[]>(
() => graph.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
className: "workflow-edge"
})),
[graph.edges]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
onGraphChange({
...graph,
nodes: nextNodes.map((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
if (!current) throw new Error(`Unknown Workflow node: ${flowNode.id}`);
return { ...current, position: flowNode.position };
}),
edges: graph.edges.filter(
(edge) => ids.has(edge.source) && ids.has(edge.target)
)
});
};
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "output",
target_port: edge.targetHandle ?? "input"
}))
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
},
{ allowCycles: allowsCycles }
) === null;
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
const type = event.dataTransfer.getData(
"application/x-govoplan-workflow-node"
);
if (!type) return;
const node = newWorkflowNode(
type,
instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
nodeLibrary
);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectNode(node.id);
};
return (
<div
className="workflow-canvas"
onDragOver={(event) => {
if (
event.dataTransfer.types.includes(
"application/x-govoplan-workflow-node"
)
) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={onDrop}
>
<ReactFlow<WorkflowFlowNode, Edge>
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
if (!readOnly) updateNodes(applyNodeChanges(changes, nodes));
}}
onEdgesChange={(changes) => {
if (!readOnly) updateEdges(applyEdgeChanges(changes, edges));
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
updateEdges(addEdge({
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
connectionRadius={32}
fitView
fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }}
minZoom={0.25}
maxZoom={1.8}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
<MiniMap
pannable
zoomable
nodeStrokeWidth={2}
nodeColor={(node) =>
node.data.hasError ? "var(--danger)" : "var(--accent)"
}
/>
<Controls showInteractive={false} />
</ReactFlow>
{!graph.nodes.length ? (
<div className="workflow-canvas-empty">Drop a start node here</div>
) : null}
</div>
);
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
): WorkflowGraph {
return {
...graph,
nodes: graph.nodes.map((node) =>
node.id === updatedNode.id ? updatedNode : node
)
};
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DismissibleAlert,
FormField
} from "@govoplan/core-webui";
import type {
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
nodeLibrary,
readOnly,
onChange,
onDelete
}: {
node: WorkflowGraphNode | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
useEffect(() => {
if (!node) {
setJsonDrafts({});
setError("");
return;
}
const definition = nodeLibrary.find((item) => item.type === node.type);
setJsonDrafts(Object.fromEntries(
(definition?.config_fields ?? [])
.filter((field) => field.kind === "mapping")
.map((field) => [
field.id,
JSON.stringify(node.config[field.id] ?? {}, null, 2)
])
));
setError("");
}, [node?.id, nodeLibrary]);
if (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
<div className="workflow-inspector-empty">No node selected</div>
</aside>
);
}
const definition = nodeLibrary.find((item) => item.type === node.type);
const updateConfig = (field: string, value: unknown) => {
onChange({
...node,
config: { ...node.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading">
<span>
<strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<FormField label="Name">
<input
value={node.label}
onChange={(event) =>
onChange({ ...node, label: event.target.value })
}
disabled={readOnly}
/>
</FormField>
{(definition?.config_fields ?? []).map((field) => (
<FormField
key={field.id}
label={`${field.label}${field.required ? " *" : ""}`}
help={field.description ?? undefined}
>
{field.kind === "select" ? (
<select
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
>
<option value="">Choose</option>
{field.options.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : field.kind === "textarea" || field.kind === "expression" ? (
<textarea
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
) : field.kind === "mapping" ? (
<textarea
className="workflow-json-editor"
value={jsonDrafts[field.id] ?? "{}"}
onChange={(event) => setJsonDrafts((current) => ({
...current,
[field.id]: event.target.value
}))}
onBlur={() => {
try {
const parsed: unknown = JSON.parse(
jsonDrafts[field.id] ?? "{}"
);
if (!isRecord(parsed)) {
throw new Error(`${field.label} must be a JSON object.`);
}
setError("");
updateConfig(field.id, parsed);
} catch (parseError) {
setError(
parseError instanceof Error
? parseError.message
: `${field.label} is invalid.`
);
}
}}
spellCheck={false}
disabled={readOnly}
/>
) : field.kind === "string_list" ? (
<input
value={stringList(node.config[field.id]).join(", ")}
onChange={(event) => updateConfig(
field.id,
event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
)}
disabled={readOnly}
/>
) : (
<input
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
)}
</FormField>
))}
</div>
</aside>
);
}
function textValue(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -0,0 +1,99 @@
import {
CalendarClock,
CheckCircle2,
CirclePlay,
CircleX,
ClipboardCheck,
GitFork,
PlugZap,
Radio,
Split,
SquareCheckBig,
Timer,
Waypoints,
type LucideIcon
} from "lucide-react";
import {
Handle,
Position,
type Node,
type NodeProps
} from "@xyflow/react";
import type { WorkflowNodeType } from "../../api/workflow";
export type WorkflowFlowNodeData = {
label: string;
workflowType: string;
definition: WorkflowNodeType;
hasError: boolean;
};
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
const iconByName: Record<string, LucideIcon> = {
"calendar-clock": CalendarClock,
"circle-check-big": CheckCircle2,
"circle-play": CirclePlay,
"circle-x": CircleX,
"clipboard-check": ClipboardCheck,
"plug-zap": PlugZap,
radio: Radio,
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints
};
export default function WorkflowNode({
data,
selected,
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
<div
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
className="workflow-node-handle"
style={{ top: portPosition(index, inputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
<span className="workflow-node-icon"><Icon size={17} /></span>
<span className="workflow-node-copy">
<strong>{data.label}</strong>
<small>{data.definition.label}</small>
</span>
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
className="workflow-node-handle"
style={{ top: portPosition(index, outputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
</div>
);
}
function portPosition(index: number, count: number): string {
return `${((index + 1) / (count + 1)) * 100}%`;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,193 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType,
WorkflowStatus
} from "../../api/workflow";
export type WorkflowDraft = {
id?: string;
name: string;
description: string;
status: WorkflowStatus;
currentRevision?: number;
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
};
const inputPort = [{
id: "input",
label: "Input",
required: true,
multiple: false,
minimum_connections: 1
}];
const outputPort = [{
id: "output",
label: "Output",
required: true,
multiple: false,
minimum_connections: 1
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "workflow.start.manual",
category: "trigger",
category_label: "Start",
label: "Manual start",
description: "Start through an explicit action.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: { input_schema_ref: "" }
},
{
type: "workflow.activity",
category: "activity",
category_label: "Activities",
label: "Activity",
description: "A governed unit of work.",
icon: "square-check-big",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
{
id: "title",
label: "Title",
kind: "text",
required: true,
options: []
}
],
default_config: {
title: "",
instructions: "",
assignee: "",
due_after: ""
}
},
{
type: "workflow.end.completed",
category: "outcome",
category_label: "Outcomes",
label: "Completed",
description: "Complete successfully.",
icon: "circle-check-big",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: { output_mapping: {} }
}
];
export function sampleWorkflowDraft(): WorkflowDraft {
return {
name: "New workflow",
description: "",
status: "draft",
metadata: {},
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "workflow.start.manual",
label: "Start",
position: { x: 60, y: 150 },
config: { input_schema_ref: "" }
},
{
id: "activity",
type: "workflow.activity",
label: "Activity",
position: { x: 320, y: 150 },
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: ""
}
},
{
id: "complete",
type: "workflow.end.completed",
label: "Completed",
position: { x: 580, y: 150 },
config: { output_mapping: {} }
}
],
edges: [
{
id: "start-activity",
source: "start",
target: "activity",
source_port: "output",
target_port: "input"
},
{
id: "activity-complete",
source: "activity",
target: "complete",
source_port: "output",
target_port: "input"
}
]
}
};
}
export function draftFromDefinition(
definition: WorkflowDefinition
): WorkflowDraft {
return {
id: definition.id,
name: definition.name,
description: definition.description ?? "",
status: definition.status,
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph)
};
}
export function workflowPayload(
draft: WorkflowDraft
): WorkflowDefinitionPayload {
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata
};
}
export function workflowFingerprint(
draft: WorkflowDraft | null
): string {
if (!draft) return "";
return JSON.stringify({
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata
});
}
export function newWorkflowNode(
type: string,
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
return createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
}

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { workflowModule as default, workflowModule } from "./module";
export * from "./api/workflow";

45
webui/src/module.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/workflow.css";
import "@xyflow/react/dist/style.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
export const workflowModule: PlatformWebModule = {
id: "workflow",
label: "Workflow",
version: "0.1.14",
optionalDependencies: [
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks"
],
navItems: [
{
to: "/workflow",
label: "Workflow",
iconName: "workflow",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/workflow",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
]
};
export default workflowModule;

View File

@@ -0,0 +1,705 @@
.workflow-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {
box-sizing: border-box;
}
.workflow-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.workflow-definition-panel,
.workflow-workspace,
.workflow-editor,
.workflow-editor-surface,
.workflow-canvas,
.workflow-definition-list-frame,
.workflow-inspector-column {
min-width: 0;
min-height: 0;
}
.workflow-definition-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.workflow-panel-toolbar,
.workflow-workspace-toolbar,
.workflow-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.workflow-panel-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.workflow-toolbar-actions,
.workflow-command-bar,
.workflow-identity-fields {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-definition-search {
padding: 10px;
border-bottom: var(--border-line);
}
.workflow-definition-search input {
min-height: 34px;
padding: 7px 9px;
}
.workflow-definition-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.workflow-definition-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.workflow-definition-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.workflow-definition-list > button:hover,
.workflow-definition-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.workflow-definition-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.workflow-definition-list > button > span:first-child {
min-width: 0;
}
.workflow-definition-list strong,
.workflow-definition-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-definition-list strong {
color: var(--text-strong);
font-size: 13px;
}
.workflow-definition-list small {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.workflow-definition-empty,
.workflow-inspector-empty {
display: grid;
min-height: 100px;
place-items: center;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.workflow-workspace {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.workflow-workspace-toolbar {
min-height: 58px;
padding: 8px 10px;
}
.workflow-identity-fields {
min-width: 240px;
flex: 1 1 auto;
}
.workflow-identity-fields input,
.workflow-revision-select {
min-height: 34px;
padding: 7px 9px;
}
.workflow-name-input {
max-width: 250px;
color: var(--text-strong);
font-weight: 700;
}
.workflow-description-input {
min-width: 140px;
flex: 1 1 240px;
}
.workflow-revision-select {
width: 142px;
}
.workflow-command-bar {
justify-content: flex-end;
flex-wrap: wrap;
}
.workflow-command-bar .btn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
white-space: nowrap;
}
.workflow-alerts {
position: absolute;
z-index: 12;
top: 66px;
right: 12px;
display: grid;
width: min(500px, calc(100% - 24px));
gap: 6px;
pointer-events: none;
}
.workflow-alerts .alert {
pointer-events: auto;
box-shadow: var(--shadow-popover);
}
.workflow-editor {
display: grid;
grid-template-columns: 210px minmax(0, 1fr) minmax(260px, 310px);
flex: 1 1 auto;
overflow: hidden;
}
.workflow-palette,
.workflow-inspector,
.workflow-inspector-column {
overflow: hidden;
background: var(--panel-soft);
}
.workflow-palette {
border-right: var(--border-line);
}
.workflow-inspector-column {
display: flex;
flex-direction: column;
border-left: var(--border-line);
}
.workflow-inspector {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.workflow-panel-heading {
min-height: 44px;
padding: 8px 10px 8px 12px;
}
.workflow-panel-heading > span {
min-width: 0;
}
.workflow-panel-heading strong,
.workflow-panel-heading small {
display: block;
}
.workflow-panel-heading strong {
color: var(--text-strong);
font-size: 13px;
}
.workflow-panel-heading small {
overflow: hidden;
margin-top: 2px;
color: var(--muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-palette-items {
display: grid;
height: calc(100% - 44px);
gap: 4px;
overflow: auto;
padding: 8px;
}
.workflow-palette-group {
display: grid;
gap: 2px;
}
.workflow-palette-group + .workflow-palette-group {
margin-top: 6px;
padding-top: 8px;
border-top: var(--border-line);
}
.workflow-palette-group h3 {
margin: 0;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
padding: 3px 8px;
text-transform: uppercase;
}
.workflow-palette-items button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 14px;
align-items: center;
gap: 7px;
min-height: 38px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: grab;
font: inherit;
font-size: 12px;
padding: 7px 8px;
text-align: left;
}
.workflow-palette-items button:hover:not(:disabled),
.workflow-palette-items button:focus-visible:not(:disabled) {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.workflow-palette-items button:disabled {
cursor: default;
opacity: .42;
}
.workflow-palette-add {
color: var(--accent);
}
.workflow-editor-surface {
position: relative;
overflow: hidden;
background: var(--bg);
}
.workflow-canvas {
position: absolute;
inset: 0;
}
.workflow-canvas .react-flow {
background: var(--bg);
}
.workflow-canvas .react-flow__background {
color: var(--line-dark);
}
.workflow-canvas .react-flow__controls,
.workflow-canvas .react-flow__minimap {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-xs);
}
.workflow-canvas .react-flow__controls-button {
border-bottom: var(--border-line);
background: var(--panel);
color: var(--text);
}
.workflow-canvas .react-flow__minimap-mask {
fill: color-mix(in srgb, var(--bg) 76%, transparent);
}
.workflow-canvas .react-flow__edge-path {
stroke: var(--line-dark);
stroke-width: 2;
}
.workflow-canvas .react-flow__edge.selected .react-flow__edge-path,
.workflow-canvas .react-flow__edge:hover .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.workflow-canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
}
.workflow-node {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 190px;
min-height: 56px;
border: 1px solid var(--line-dark);
border-left: 4px solid #3d6f9e;
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow-xs);
padding: 8px 10px;
}
.workflow-node-trigger {
border-left-color: #2f7d6d;
}
.workflow-node-activity {
border-left-color: #3d6f9e;
}
.workflow-node-decision {
border-left-color: #b7791f;
}
.workflow-node-wait {
border-left-color: #8a6d3b;
}
.workflow-node-integration {
border-left-color: #76569b;
}
.workflow-node-outcome {
border-left-color: #9d4e63;
}
.workflow-node.is-selected {
border-color: var(--accent);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent),
var(--shadow-xs);
}
.workflow-node.has-error {
border-color: var(--danger-text);
}
.workflow-node-icon {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
border-radius: 5px;
background: var(--panel-soft);
color: var(--text-strong);
}
.workflow-node-copy {
min-width: 0;
flex: 1 1 auto;
}
.workflow-node-copy strong,
.workflow-node-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-node-copy strong {
color: var(--text-strong);
font-size: 12px;
}
.workflow-node-copy small {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
}
.workflow-node-handle {
width: 13px;
height: 13px;
border: 3px solid var(--panel);
background: var(--line-dark);
}
.workflow-node-handle:hover,
.workflow-node-handle.connectingto,
.workflow-node-handle.valid {
width: 17px;
height: 17px;
background: var(--accent);
}
.workflow-inspector-fields {
display: grid;
gap: 12px;
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 12px;
}
.workflow-inspector-fields input,
.workflow-inspector-fields select,
.workflow-inspector-fields textarea {
margin-top: 5px;
padding: 7px 8px;
font-size: 12px;
}
.workflow-inspector-fields textarea {
min-height: 82px;
resize: vertical;
}
.workflow-json-editor {
min-height: 150px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.45;
}
.workflow-inspector-delete {
color: var(--danger-text);
}
.workflow-diagnostics {
display: flex;
max-height: 38%;
min-height: 120px;
flex: 0 1 38%;
flex-direction: column;
border-top: var(--border-line-dark);
}
.workflow-diagnostics > div:last-child {
overflow: auto;
padding: 5px;
}
.workflow-diagnostics button {
display: block;
width: 100%;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px;
text-align: left;
}
.workflow-diagnostics button:hover {
background: var(--primary-soft);
}
.workflow-diagnostics button strong,
.workflow-diagnostics button small {
display: block;
}
.workflow-diagnostics button strong {
font-size: 11px;
}
.workflow-diagnostics button small {
margin-top: 3px;
color: var(--muted);
font-size: 9px;
}
.workflow-workspace-empty {
display: grid;
height: 100%;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--muted);
}
.workflow-workspace-empty strong {
color: var(--text-strong);
}
.workflow-workspace-empty .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.workflow-working-indicator {
position: absolute;
z-index: 20;
right: 12px;
bottom: 10px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-popover);
color: var(--text-strong);
font-size: 12px;
padding: 7px 10px;
}
@media (max-width: 1180px) {
.workflow-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.workflow-workspace-toolbar {
align-items: stretch;
flex-direction: column;
}
.workflow-command-bar {
justify-content: flex-start;
}
.workflow-editor {
grid-template-columns: 180px minmax(0, 1fr) 270px;
}
}
@media (max-width: 900px) {
.workflow-shell {
grid-template-columns: 210px minmax(0, 1fr);
}
.workflow-editor {
grid-template-columns: 140px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
}
.workflow-inspector-column {
grid-column: 1 / -1;
border-top: var(--border-line);
border-left: 0;
}
}
@media (max-width: 680px) {
.workflow-page {
height: calc(100vh - 94px);
}
.workflow-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
}
.workflow-definition-panel {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-identity-fields,
.workflow-command-bar {
flex-wrap: wrap;
}
.workflow-name-input,
.workflow-description-input {
max-width: none;
min-width: 180px;
}
.workflow-editor {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
}
.workflow-palette {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-palette .workflow-panel-heading {
display: none;
}
.workflow-palette-items {
display: flex;
overflow-x: auto;
}
.workflow-palette-group {
display: flex;
flex: 0 0 auto;
}
.workflow-palette-group h3 {
display: none;
}
.workflow-palette-items button {
min-width: 128px;
}
}

16
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModules: PlatformWebModule[];
export { installedWebModules };
export default installedWebModules;
}

33
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}