Implement native BPMN workflows and guided modes
This commit is contained in:
+189
-2
@@ -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,
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user