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
+189 -2
View File
@@ -6,6 +6,7 @@ import {
} from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphEdge,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
@@ -13,10 +14,39 @@ import type {
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
export type WorkflowExecutionMode = "guided" | "automated" | "hybrid";
export type WorkflowStartOrigin =
| "user"
| "api"
| "schedule"
| "event"
| "parent_workflow"
| "dependency"
| "retry"
| "replay"
| "backfill";
export type WorkflowGraphNode = DefinitionGraphNode & {
size?: { width: number; height: number } | null;
parent_id?: string | null;
process_id?: string | null;
};
export type WorkflowGraphEdge = DefinitionGraphEdge & {
type:
| "bpmn.sequenceFlow"
| "bpmn.messageFlow"
| "bpmn.association"
| "bpmn.dataInputAssociation"
| "bpmn.dataOutputAssociation"
| "bpmn.conversationLink";
label: string;
config: Record<string, unknown>;
waypoints: Array<{ x: number; y: number }>;
};
export type WorkflowGraph = Omit<DefinitionGraph, "nodes" | "edges"> & {
schema_version: 1;
nodes: WorkflowGraphNode[];
edges: WorkflowGraphEdge[];
metadata: Record<string, unknown>;
};
export type WorkflowDiagnostic = {
@@ -29,6 +59,71 @@ export type WorkflowDiagnostic = {
export type WorkflowNodeType = DefinitionGraphNodeType;
export type BpmnRuntimeKind = "model_only" | "native_graph" | "external";
export type BpmnDiagnostic = {
severity: "error" | "warning" | "info";
code: string;
message: string;
element_id?: string | null;
};
export type BpmnAdapterProfile = {
id: string;
version: string;
label: string;
description: string;
conformance: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
supported_elements: string[];
supported_event_definitions: string[];
requirements: string[];
};
export type BpmnInspection = {
valid_xml: boolean;
definitions_id?: string | null;
target_namespace?: string | null;
process_count: number;
executable_process_count: number;
collaboration_count: number;
choreography_count: number;
element_counts: Record<string, number>;
support_counts: Record<string, number>;
elements: Array<{
element_type: string;
element_id?: string | null;
name?: string | null;
parent_type?: string | null;
parent_id?: string | null;
support_level: "interchange_only" | "native_mapping" | "native_execution";
}>;
diagnostics: BpmnDiagnostic[];
adapter_id?: string | null;
adapter_version?: string | null;
runtime_kind?: BpmnRuntimeKind | null;
executable: boolean;
activatable: boolean;
};
export type BpmnRevisionSummary = {
format: "bpmn-2.0";
content_hash: string;
adapter_id: string;
adapter_version: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
adapter_available: boolean;
};
export type BpmnRevisionDocument = BpmnRevisionSummary & {
definition_id: string;
revision: number;
xml: string;
inspection: BpmnInspection;
};
export type WorkflowRevision = {
id: string;
revision: number;
@@ -37,6 +132,10 @@ export type WorkflowRevision = {
content_hash: string;
library_id: string;
library_version: string;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
bpmn?: BpmnRevisionSummary | null;
created_by?: string | null;
created_at: string;
};
@@ -134,6 +233,15 @@ export type WorkflowInstance = {
definition_name: string;
definition_revision: number;
definition_hash: string;
execution_mode: WorkflowExecutionMode;
start_origin: WorkflowStartOrigin;
view_context?: {
view_id: string;
revision_id?: string | null;
visible_surface_ids: string[];
step_id?: string | null;
node_id?: string | null;
} | null;
status: WorkflowInstanceStatus;
idempotency_key: string;
correlation_id?: string | null;
@@ -157,6 +265,11 @@ export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
bpmn?: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
} | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
@@ -165,8 +278,68 @@ export type WorkflowDefinitionPayload = {
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
};
export async function getBpmnSupportProfile(
settings: ApiSettings
): Promise<{
specification: string;
model_namespace: string;
interchange: string;
native_runtime: string;
native_execution_elements: string[];
native_mapping_elements: string[];
adapters: BpmnAdapterProfile[];
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/profile");
}
export function inspectWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
activation?: boolean;
}
): Promise<BpmnInspection> {
return apiFetch(settings, "/api/v1/workflow/bpmn/inspect", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function compileWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
}
): Promise<{
adapter: BpmnAdapterProfile;
graph: WorkflowGraph;
inspection: BpmnInspection;
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/compile", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function renderWorkflowBpmn(
settings: ApiSettings,
payload: { graph: WorkflowGraph; name?: string }
): Promise<{ xml: string; inspection: BpmnInspection }> {
return apiFetch(settings, "/api/v1/workflow/bpmn/render", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
@@ -224,6 +397,9 @@ export function deriveWorkflowDefinition(
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode?: WorkflowExecutionMode | null;
view_id?: string | null;
view_revision_id?: string | null;
}
): Promise<WorkflowDefinition> {
return apiFetch(
@@ -244,6 +420,17 @@ export async function listWorkflowRevisions(
return response.revisions;
}
export function getWorkflowRevisionBpmn(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<BpmnRevisionDocument> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}/bpmn`
);
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
+168 -20
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type DragEvent } from "react";
import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
@@ -7,8 +7,10 @@ import {
BackgroundVariant,
ConnectionLineType,
Controls,
MarkerType,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
@@ -17,6 +19,7 @@ import { definitionConnectionError } from "@govoplan/core-webui/definition-graph
import type {
WorkflowDiagnostic,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
@@ -30,23 +33,29 @@ export default function WorkflowCanvas({
diagnostics,
nodeLibrary,
selectedNodeId,
selectedEdgeId,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode
onSelectNode,
onSelectEdge
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
selectedEdgeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
onSelectEdge: (edgeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | null
>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const definitions = useMemo(
() => new Map(nodeLibrary.map((item) => [item.type, item])),
[nodeLibrary]
@@ -67,8 +76,8 @@ export default function WorkflowCanvas({
id: node.id,
type: "workflow" as const,
position: node.position,
initialWidth: 190,
initialHeight: 56,
initialWidth: canvasNodeSize(node, definition).width,
initialHeight: canvasNodeSize(node, definition).height,
selected: node.id === selectedNodeId,
data: {
label: node.label,
@@ -88,13 +97,34 @@ export default function WorkflowCanvas({
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
className: "workflow-edge"
label: edge.label || undefined,
className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`,
selected: edge.id === selectedEdgeId,
animated: edge.type === "bpmn.messageFlow",
markerEnd: edgeMarkerEnd(edge),
style: edge.type === "bpmn.association"
? { strokeDasharray: "4 4" }
: edge.type === "bpmn.messageFlow"
? { strokeDasharray: "8 5" }
: undefined
})),
[graph.edges]
[graph.edges, selectedEdgeId]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
const movedIds = new Set(
nextNodes
.filter((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
return current
&& (
current.position.x !== flowNode.position.x
|| current.position.y !== flowNode.position.y
);
})
.map((node) => node.id)
);
onGraphChange({
...graph,
nodes: nextNodes.map((flowNode) => {
@@ -104,6 +134,10 @@ export default function WorkflowCanvas({
}),
edges: graph.edges.filter(
(edge) => ids.has(edge.source) && ids.has(edge.target)
).map((edge) =>
movedIds.has(edge.source) || movedIds.has(edge.target)
? { ...edge, waypoints: [] }
: edge
)
});
};
@@ -111,20 +145,43 @@ export default function WorkflowCanvas({
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"
}))
edges: nextEdges.map((edge) => {
const current = graph.edges.find((item) => item.id === edge.id);
const endpointsChanged = Boolean(
current
&& (
current.source !== edge.source
|| current.target !== edge.target
)
);
return {
id: edge.id,
type: current?.type ?? "bpmn.sequenceFlow",
label: current?.label ?? "",
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "outgoing",
target_port: edge.targetHandle ?? "incoming",
config: structuredClone(current?.config ?? {}),
waypoints: endpointsChanged
? []
: structuredClone(current?.waypoints ?? [])
} satisfies WorkflowGraphEdge;
})
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
reconnectingEdgeId.current
? {
...graph,
edges: graph.edges.filter(
(edge) => edge.id !== reconnectingEdgeId.current
)
}
: graph,
nodeLibrary,
{
source: connection.source,
@@ -182,21 +239,66 @@ export default function WorkflowCanvas({
}
}}
onEdgesChange={(changes) => {
if (!readOnly) updateEdges(applyEdgeChanges(changes, edges));
if (readOnly) return;
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
onSelectEdge(selectedChange.id);
}
const graphChanges = changes.filter(
(change) => change.type !== "select"
);
if (graphChanges.length) {
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
updateEdges(addEdge({
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
}}
onReconnect={(oldEdge, connection) => {
if (readOnly || !isValidConnection(connection)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
}}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
onSelectEdge(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
onNodeClick={(_event, node) => {
onSelectEdge(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
onSelectNode(null);
onSelectEdge(edge.id);
}}
onPaneClick={() => {
onSelectNode(null);
onSelectEdge(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
@@ -224,6 +326,52 @@ export default function WorkflowCanvas({
);
}
function edgeMarkerEnd(edge: WorkflowGraphEdge) {
if (edge.type === "bpmn.sequenceFlow") {
return {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
if (
edge.type === "bpmn.messageFlow"
|| edge.type === "bpmn.dataInputAssociation"
|| edge.type === "bpmn.dataOutputAssociation"
) {
return {
type: MarkerType.Arrow,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
return undefined;
}
function canvasNodeSize(
node: WorkflowGraphNode,
definition: WorkflowNodeType
): { width: number; height: number } {
const shape = String(definition.metadata?.shape ?? "activity");
if (shape.startsWith("event")) return { width: 124, height: 70 };
if (shape === "gateway") return { width: 124, height: 82 };
if (shape === "participant" || shape === "lane") {
return {
width: Math.max(240, Math.min(node.size?.width ?? 360, 720)),
height: Math.max(100, Math.min(node.size?.height ?? 160, 360))
};
}
if (shape === "group") {
return {
width: Math.max(220, node.size?.width ?? 300),
height: Math.max(120, node.size?.height ?? 180)
};
}
return { width: 190, height: 64 };
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
@@ -1,30 +1,64 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DismissibleAlert,
FormField
FormField,
ReferenceMultiSelect,
useViewSurfaces,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
edge,
nodeLibrary,
readOnly,
onChange,
onDelete
onDelete,
onEdgeChange,
onEdgeDelete
}: {
node: WorkflowGraphNode | null;
edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void;
onEdgeDelete: (edgeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
const viewSurfaces = useViewSurfaces();
const viewSurfaceProvider = useMemo<ReferenceOptionProvider>(() => {
const options = viewSurfaces.map((surface) => ({
value: surface.id,
label: surface.label,
description: `${surface.moduleId} · ${surface.kind}`,
searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}`
}));
const byId = new Map(options.map((option) => [option.value, option]));
return {
search: async (query, context) => {
const normalized = query.trim().toLowerCase();
return options
.filter((option) => (
!normalized
|| option.searchText.toLowerCase().includes(normalized)
))
.slice(0, context.limit);
},
resolve: async (values) => values
.map((value) => byId.get(value))
.filter((option): option is (typeof options)[number] => Boolean(option))
};
}, [viewSurfaces]);
useEffect(() => {
if (!node) {
@@ -44,6 +78,109 @@ export default function WorkflowInspector({
setError("");
}, [node?.id, nodeLibrary]);
if (edge) {
const updateEdgeConfig = (field: string, value: unknown) => {
onEdgeChange({
...edge,
config: { ...edge.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Flow inspector">
<div className="workflow-panel-heading">
<span>
<strong>Flow</strong>
<small>{edge.type.replace(/^bpmn\./, "")}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onEdgeDelete(edge.id)}
disabled={readOnly}
aria-label="Delete flow"
title="Delete flow"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
<FormField label="Flow type">
<select
value={edge.type}
onChange={(event) => onEdgeChange({
...edge,
type: event.target.value as WorkflowGraphEdge["type"]
})}
disabled={readOnly}
>
<option value="bpmn.sequenceFlow">Sequence flow</option>
<option value="bpmn.messageFlow">Message flow</option>
<option value="bpmn.association">Association</option>
<option value="bpmn.dataInputAssociation">
Data input association
</option>
<option value="bpmn.dataOutputAssociation">
Data output association
</option>
<option value="bpmn.conversationLink">
Conversation link
</option>
</select>
</FormField>
<FormField label="Name">
<input
value={edge.label}
onChange={(event) => onEdgeChange({
...edge,
label: event.target.value
})}
disabled={readOnly}
/>
</FormField>
{edge.type === "bpmn.sequenceFlow" ? (
<>
<FormField
label="Condition"
help="A constrained expression evaluated when this flow is reached."
>
<textarea
value={textValue(edge.config.condition)}
onChange={(event) =>
updateEdgeConfig("condition", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<FormField
label="Runtime outcome"
help="Optional GovOPlaN task outcome mapped to this sequence flow."
>
<input
value={textValue(edge.config.outcome)}
onChange={(event) =>
updateEdgeConfig("outcome", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<label className="workflow-inspector-checkbox">
<input
type="checkbox"
checked={edge.config.default === true}
onChange={(event) =>
updateEdgeConfig("default", event.target.checked)
}
disabled={readOnly}
/>
Default flow
</label>
</>
) : null}
</div>
</aside>
);
}
if (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
@@ -158,6 +295,16 @@ export default function WorkflowInspector({
)}
disabled={readOnly}
/>
) : field.kind === "view_surfaces" ? (
<ReferenceMultiSelect
values={stringList(node.config[field.id])}
onChange={(values) => updateConfig(field.id, values)}
provider={viewSurfaceProvider}
aria-label={field.label}
placeholder="Add a visible surface"
searchPlaceholder="Search modules and interface surfaces"
disabled={readOnly}
/>
) : (
<input
value={textValue(node.config[field.id])}
+61 -1
View File
@@ -1,4 +1,33 @@
import {
Asterisk,
BadgeDollarSign,
BoxSelect,
Braces,
CircleDashed,
CircleDot,
CircleDotDashed,
CirclePlus,
CircleStop,
Cog,
Database,
Diamond,
ExternalLink,
File,
FileCode2,
GitBranch,
Hand,
Inbox,
MessagesSquare,
Plus,
RadioTower,
RectangleHorizontal,
Rows3,
Scale,
Send,
Shuffle,
Square,
TextQuote,
UserRoundCheck,
CalendarClock,
CheckCircle2,
CirclePlay,
@@ -41,7 +70,36 @@ const iconByName: Record<string, LucideIcon> = {
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints
waypoints: Waypoints,
asterisk: Asterisk,
"badge-dollar-sign": BadgeDollarSign,
"box-select": BoxSelect,
braces: Braces,
"circle-dashed": CircleDashed,
"circle-dot": CircleDot,
"circle-dot-dashed": CircleDotDashed,
"circle-plus": CirclePlus,
"circle-stop": CircleStop,
cog: Cog,
database: Database,
diamond: Diamond,
"external-link": ExternalLink,
file: File,
"file-code-2": FileCode2,
"git-branch": GitBranch,
hand: Hand,
inbox: Inbox,
"messages-square": MessagesSquare,
plus: Plus,
"radio-tower": RadioTower,
"rectangle-horizontal": RectangleHorizontal,
"rows-3": Rows3,
scale: Scale,
send: Send,
shuffle: Shuffle,
square: Square,
"text-quote": TextQuote,
"user-round-check": UserRoundCheck
};
export default function WorkflowNode({
@@ -50,6 +108,7 @@ export default function WorkflowNode({
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const shape = String(data.definition.metadata?.shape ?? "activity");
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
@@ -57,6 +116,7 @@ export default function WorkflowNode({
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
`workflow-node-shape-${shape}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { ListChecks } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listWorkflowInstances,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
export default function WorkflowOpenWorkWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 6, 1, 20);
const includeRunning = configuration.includeRunning !== false;
const load = useCallback(async () => {
const instances = await listWorkflowInstances(settings);
return instances
.filter((instance) =>
instance.status === "waiting"
|| (includeRunning && instance.status === "running")
)
.sort(compareOpenWork)
.slice(0, maxItems);
}, [includeRunning, maxItems, settings]);
const { data: instances, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading open workflow work">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No workflow work is currently open."
items={(instances ?? []).map((instance) => {
const step = currentStep(instance);
return {
id: instance.id,
title: instance.definition_name,
detail: handoffTitle(step),
meta: updatedLabel(instance.updated_at),
leading: <ListChecks size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={instance.status}
label={instance.status === "waiting" ? "Waiting" : "Running"}
/>
),
to: workflowRunUrl(instance)
};
})}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/workflow">
Open Workflow
</Link>
</div>
</LoadingFrame>
);
}
function currentStep(
instance: WorkflowInstance
): WorkflowInstanceStep | null {
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffTitle(step: WorkflowInstanceStep | null): string {
const title = step?.handoff.title;
if (typeof title === "string" && title.trim()) return title;
if (!step) return "Preparing next step";
return step.node_type
.replace(/^workflow\./, "")
.split(".")
.join(" ");
}
function workflowRunUrl(instance: WorkflowInstance): string {
const query = new URLSearchParams({
definition: instance.definition_id,
run: instance.id
});
return `/workflow?${query.toString()}`;
}
function compareOpenWork(
left: WorkflowInstance,
right: WorkflowInstance
): number {
if (left.status !== right.status) {
return left.status === "waiting" ? -1 : 1;
}
return (
new Date(right.updated_at).getTime()
- new Date(left.updated_at).getTime()
);
}
function updatedLabel(value: string): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
+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.";
@@ -23,8 +23,13 @@ import {
FormField,
IconButton,
LoadingFrame,
StageRail,
StatusBadge,
type ApiSettings
dispatchWorkflowViewChanged,
usePlatformUiCapability,
type StageRailTone,
type ApiSettings,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
@@ -50,6 +55,7 @@ export default function WorkflowRunsDialog({
open,
settings,
definition,
initialInstanceId,
canStart,
canTransition,
onClose
@@ -57,6 +63,7 @@ export default function WorkflowRunsDialog({
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
initialInstanceId?: string | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
@@ -69,6 +76,9 @@ export default function WorkflowRunsDialog({
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
"views.runtime"
);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
@@ -99,7 +109,10 @@ export default function WorkflowRunsDialog({
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
items.some((item) => item.id === current)
initialInstanceId
&& items.some((item) => item.id === initialInstanceId)
? initialInstanceId
: items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
@@ -108,7 +121,7 @@ export default function WorkflowRunsDialog({
} finally {
setLoading(false);
}
}, [definition?.id, open, settings]);
}, [definition?.id, initialInstanceId, open, settings]);
useEffect(() => {
if (!open) return;
@@ -117,6 +130,39 @@ export default function WorkflowRunsDialog({
void load();
}, [load, open]);
useEffect(() => {
if (!open || !selected) return;
const context = selected.view_context;
if (!context || !viewsRuntime) {
dispatchWorkflowViewChanged(null);
return;
}
let cancelled = false;
void viewsRuntime.resolveWorkflowView(settings, {
viewId: context.view_id,
revisionId: context.revision_id,
visibleSurfaceIds: context.visible_surface_ids
}).then((projection) => {
if (!cancelled) {
dispatchWorkflowViewChanged(projection, selected.id);
}
}).catch((viewError) => {
if (!cancelled) {
dispatchWorkflowViewChanged(null);
setError(errorMessage(viewError));
}
});
return () => {
cancelled = true;
};
}, [
open,
selected?.id,
selected?.updated_at,
settings,
viewsRuntime
]);
useEffect(() => {
if (
!open
@@ -236,6 +282,10 @@ export default function WorkflowRunsDialog({
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
const close = () => {
dispatchWorkflowViewChanged(null);
onClose();
};
return (
<>
@@ -244,8 +294,8 @@ export default function WorkflowRunsDialog({
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
onClose={close}
footer={<Button onClick={close}>Close</Button>}
>
<div className="workflow-runs-toolbar">
<span>
@@ -419,40 +469,26 @@ export default function WorkflowRunsDialog({
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<div>
{selected.steps.map((step) => (
<div
key={step.id}
className="workflow-run-stage"
data-state={step.status}
data-current={
step.id === selected.current_step_id || undefined
}
>
<span className="workflow-run-stage-marker">
{step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)}
</span>
<span className="workflow-run-stage-copy">
<strong>{step.node_id}</strong>
<small>
{step.node_type} · attempt {step.attempt}
</small>
<StatusBadge
status={step.status}
label={step.status}
/>
</span>
</div>
))}
</div>
<StageRail
ariaLabel="Workflow instance progress"
items={selected.steps.map((step) => ({
id: step.id,
label: step.node_id,
detail: `${step.node_type} · attempt ${step.attempt}`,
statusLabel: step.status,
current: step.id === selected.current_step_id,
tone: stepTone(step.status),
icon: step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)
}))}
/>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
@@ -530,6 +566,15 @@ function actionLabel(action: WorkflowAction): string {
}[action];
}
function stepTone(
status: WorkflowInstanceStep["status"]
): StageRailTone {
if (status === "completed") return "success";
if (status === "running" || status === "waiting") return "active";
if (status === "failed" || status === "cancelled") return "danger";
return "neutral";
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
+131 -42
View File
@@ -7,6 +7,7 @@ import type {
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowExecutionMode,
WorkflowNodeType,
WorkflowStatus
} from "../../api/workflow";
@@ -27,44 +28,53 @@ export type WorkflowDraft = {
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
executionMode: WorkflowExecutionMode;
viewId: string;
viewRevisionId: string;
governance: WorkflowGovernance | null;
};
const inputPort = [{
id: "input",
label: "Input",
id: "incoming",
label: "Incoming",
required: true,
multiple: false,
multiple: true,
minimum_connections: 1
}];
const outputPort = [{
id: "output",
label: "Output",
required: true,
multiple: false,
minimum_connections: 1
id: "outgoing",
label: "Outgoing",
required: false,
multiple: true,
minimum_connections: 0
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "workflow.start.manual",
category: "trigger",
category_label: "Start",
label: "Manual start",
description: "Start through an explicit action.",
type: "bpmn.startEvent",
category: "bpmn_event",
category_label: "Events",
label: "Start event",
description: "Start a BPMN process.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: { input_schema_ref: "" }
default_config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-start" }
},
{
type: "workflow.activity",
category: "activity",
type: "bpmn.userTask",
category: "bpmn_activity",
category_label: "Activities",
label: "Activity",
description: "A governed unit of work.",
icon: "square-check-big",
label: "User task",
description: "A governed user task.",
icon: "user-round-check",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
@@ -80,20 +90,29 @@ export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
title: "",
instructions: "",
assignee: "",
due_after: ""
}
due_after: "",
task_mode: "activity",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "activity" }
},
{
type: "workflow.end.completed",
category: "outcome",
category_label: "Outcomes",
label: "Completed",
description: "Complete successfully.",
icon: "circle-check-big",
type: "bpmn.endEvent",
category: "bpmn_event",
category_label: "Events",
label: "End event",
description: "End the BPMN process.",
icon: "circle-stop",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: { output_mapping: {} }
default_config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-end" }
}
];
@@ -110,53 +129,98 @@ export function sampleWorkflowDraft(): WorkflowDraft {
allowStart: true,
allowReuse: false,
allowAutomation: false,
executionMode: "hybrid",
viewId: "",
viewRevisionId: "",
governance: null,
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "workflow.start.manual",
type: "bpmn.startEvent",
label: "Start",
position: { x: 60, y: 150 },
config: { input_schema_ref: "" }
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
}
},
{
id: "activity",
type: "workflow.activity",
type: "bpmn.userTask",
label: "Activity",
position: { x: 320, y: 150 },
size: { width: 120, height: 80 },
process_id: "Process_1",
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: ""
due_after: "",
task_mode: "activity",
documentation: ""
}
},
{
id: "complete",
type: "workflow.end.completed",
type: "bpmn.endEvent",
label: "Completed",
position: { x: 580, y: 150 },
config: { output_mapping: {} }
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
}
}
],
edges: [
{
id: "start-activity",
type: "bpmn.sequenceFlow",
label: "",
source: "start",
target: "activity",
source_port: "output",
target_port: "input"
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
},
{
id: "activity-complete",
type: "bpmn.sequenceFlow",
label: "",
source: "activity",
target: "complete",
source_port: "output",
target_port: "input"
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
}
]
],
metadata: {
notation: "bpmn-2.0",
bpmn: {
definitions_id: "Definitions_1",
target_namespace: "urn:govoplan:workflow",
processes: [{
id: "Process_1",
name: "",
is_executable: true,
attributes: {}
}],
collaborations: [],
choreographies: [],
root_elements_xml: []
}
}
}
};
}
@@ -180,6 +244,9 @@ export function draftFromDefinition(
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
executionMode: definition.revision.execution_mode,
viewId: definition.revision.view_id ?? "",
viewRevisionId: definition.revision.view_revision_id ?? "",
governance: definition.governance
};
}
@@ -198,7 +265,10 @@ export function workflowPayload(
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation
allow_automation: draft.allowAutomation,
execution_mode: draft.executionMode,
view_id: draft.viewId || null,
view_revision_id: draft.viewRevisionId || null
};
}
@@ -217,7 +287,10 @@ export function workflowFingerprint(
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation
allowAutomation: draft.allowAutomation,
executionMode: draft.executionMode,
viewId: draft.viewId,
viewRevisionId: draft.viewRevisionId
});
}
@@ -226,9 +299,25 @@ export function newWorkflowNode(
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
return createDefinitionGraphNode<WorkflowGraphNode>(
const node = createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
const shape = library.find((item) => item.type === type)?.metadata?.shape;
return {
...node,
process_id: "Process_1",
size: defaultNodeSize(String(shape ?? "activity"))
};
}
function defaultNodeSize(shape: string): { width: number; height: number } {
if (shape.startsWith("event")) return { width: 36, height: 36 };
if (shape === "gateway") return { width: 50, height: 50 };
if (shape === "participant") return { width: 600, height: 180 };
if (shape === "lane") return { width: 560, height: 140 };
if (shape === "data-object") return { width: 36, height: 50 };
if (shape === "data-store") return { width: 50, height: 50 };
return { width: 120, height: 80 };
}
+68 -2
View File
@@ -1,13 +1,67 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "@xyflow/react/dist/style.css";
import "./styles/workflow.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const WorkflowOpenWorkWidget = lazy(
() => import("./features/workflow/WorkflowOpenWorkWidget")
);
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
const instanceReadScopes = [
"workflow:instance:read",
"workflow:instance:admin"
];
const workflowDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "workflow.open-work",
surfaceId: "workflow.widget.open-work",
title: "Open workflow work",
description: "Running workflows and steps that require attention.",
moduleId: "workflow",
category: "Work",
order: 42,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: instanceReadScopes,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 6,
includeRunning: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum items",
kind: "number",
min: 1,
max: 20,
step: 1,
required: true
},
{
id: "includeRunning",
label: "Include running steps",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(WorkflowOpenWorkWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const workflowModule: PlatformWebModule = {
id: "workflow",
@@ -22,6 +76,15 @@ export const workflowModule: PlatformWebModule = {
"policy",
"tasks"
],
viewSurfaces: [
{
id: "workflow.widget.open-work",
moduleId: "workflow",
kind: "section",
label: "Open workflow work widget",
order: 76
}
],
navItems: [
{
to: "/workflow",
@@ -39,7 +102,10 @@ export const workflowModule: PlatformWebModule = {
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
]
],
uiCapabilities: {
"dashboard.widgets": workflowDashboardWidgets
}
};
export default workflowModule;
+119 -105
View File
@@ -274,6 +274,10 @@
overflow: hidden;
}
.workflow-bpmn-file-input {
display: none;
}
.workflow-palette,
.workflow-inspector,
.workflow-inspector-column {
@@ -457,38 +461,101 @@
display: flex;
align-items: center;
gap: 9px;
width: 190px;
width: 100%;
height: 100%;
min-height: 56px;
box-sizing: border-box;
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-bpmn_activity {
border-width: 2px;
}
.workflow-node-activity {
border-left-color: #3d6f9e;
.workflow-node-bpmn_collaboration {
border-width: 2px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
}
.workflow-node-decision {
border-left-color: #b7791f;
.workflow-node-shape-participant,
.workflow-node-shape-lane {
align-items: flex-start;
padding: 12px;
}
.workflow-node-wait {
border-left-color: #8a6d3b;
.workflow-node-shape-lane {
border-width: 1px;
}
.workflow-node-integration {
border-left-color: #76569b;
.workflow-node-shape-group {
align-items: flex-start;
border: 2px dashed var(--line-dark);
background: transparent;
box-shadow: none;
}
.workflow-node-outcome {
border-left-color: #9d4e63;
.workflow-node-shape-text-annotation {
border: 0;
border-left: 2px solid var(--line-dark);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"],
.workflow-node-shape-gateway {
justify-content: flex-start;
border: 0;
background: transparent;
box-shadow: none;
padding: 4px;
}
.workflow-node[class*="workflow-node-shape-event"] .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: 50%;
background: var(--panel);
}
.workflow-node-shape-event-intermediate-catch .workflow-node-icon,
.workflow-node-shape-event-intermediate-throw .workflow-node-icon {
box-shadow: inset 0 0 0 3px var(--panel), inset 0 0 0 4px var(--text-strong);
}
.workflow-node-shape-event-boundary .workflow-node-icon {
border-style: dashed;
}
.workflow-node-shape-event-end .workflow-node-icon {
border-width: 4px;
}
.workflow-node-shape-gateway .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: 2px;
background: var(--panel);
transform: rotate(45deg);
}
.workflow-node-shape-gateway .workflow-node-icon svg {
transform: rotate(-45deg);
}
.workflow-node-shape-data-object,
.workflow-node-shape-data-store,
.workflow-node-shape-conversation,
.workflow-node-shape-choreography {
border-width: 2px;
}
.workflow-node.is-selected {
@@ -498,10 +565,26 @@
var(--shadow-xs);
}
.workflow-node[class*="workflow-node-shape-event"].is-selected,
.workflow-node-shape-gateway.is-selected {
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"].is-selected .workflow-node-icon,
.workflow-node-shape-gateway.is-selected .workflow-node-icon {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
.workflow-node.has-error {
border-color: var(--danger-text);
}
.workflow-node[class*="workflow-node-shape-event"].has-error .workflow-node-icon,
.workflow-node-shape-gateway.has-error .workflow-node-icon {
border-color: var(--danger-text);
}
.workflow-node-icon {
display: grid;
width: 28px;
@@ -552,6 +635,25 @@
background: var(--accent);
}
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
.workflow-edge-bpmn-association .react-flow__edge-path,
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
stroke-width: 1.5;
}
.workflow-inspector-checkbox {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 12px;
}
.workflow-inspector-checkbox input {
width: auto;
margin: 0;
}
.workflow-inspector-fields {
display: grid;
grid-auto-rows: max-content;
@@ -899,100 +1001,10 @@
padding: 6px 2px;
}
.workflow-run-history > div {
grid-template-columns: repeat(
auto-fit,
minmax(min(150px, 100%), 1fr)
);
overflow-x: auto;
.workflow-run-history .stage-rail {
padding: 2px 0 4px;
}
.workflow-run-stage {
--workflow-stage-color: var(--line-dark);
position: relative;
display: grid;
min-width: 140px;
grid-template-columns: 32px minmax(0, 1fr);
gap: 7px;
padding: 4px 12px 4px 0;
}
.workflow-run-stage[data-state="completed"] {
--workflow-stage-color: var(--green);
}
.workflow-run-stage[data-state="running"],
.workflow-run-stage[data-state="waiting"] {
--workflow-stage-color: var(--blue);
}
.workflow-run-stage[data-state="failed"],
.workflow-run-stage[data-state="cancelled"] {
--workflow-stage-color: var(--red);
}
.workflow-run-stage[data-state="superseded"] {
--workflow-stage-color: var(--muted);
}
.workflow-run-stage:not(:last-child)::after {
position: absolute;
z-index: 0;
top: 18px;
left: 27px;
width: calc(100% - 17px);
height: 2px;
background: linear-gradient(
90deg,
var(--workflow-stage-color),
var(--line-dark)
);
content: "";
}
.workflow-run-stage-marker {
z-index: 1;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--workflow-stage-color);
border-radius: 50%;
background: var(--panel);
color: var(--workflow-stage-color);
}
.workflow-run-stage[data-current="true"] .workflow-run-stage-marker {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-stage-color) 18%, transparent);
}
.workflow-run-stage-copy {
z-index: 1;
display: grid;
min-width: 0;
align-content: start;
gap: 3px;
padding-top: 2px;
}
.workflow-run-stage-copy strong,
.workflow-run-stage-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-stage-copy strong {
color: var(--text-strong);
font-size: 11px;
}
.workflow-run-stage-copy .status-badge {
width: fit-content;
margin-top: 2px;
}
.workflow-run-history small,
.workflow-run-events small {
color: var(--muted);
@@ -1025,6 +1037,7 @@
.workflow-editor {
grid-template-columns: 180px minmax(0, 1fr) 270px;
}
}
@media (max-width: 900px) {
@@ -1042,6 +1055,7 @@
border-top: var(--border-line);
border-left: 0;
}
}
@media (max-width: 680px) {