From f506f047b8d0688326b1b477cfed66c8a5f31e70 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Fri, 13 Mar 2026 00:48:10 +0000 Subject: [PATCH] Updated visualiser showing a flowchart style output --- .../components/VisualisetTab.tsx | 694 +++++++++++++++++- 1 file changed, 672 insertions(+), 22 deletions(-) diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index 3461fd1..4e55789 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -1,43 +1,693 @@ import React from "react"; -import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService"; +import templateVersionsService, { + CreateWorkflowTemplateVersion, + TaskMetadata, +} from "../services/WorkflowTemplateService"; import ValidationErrorIcon from "../../../../components/validationErrorIcon"; +import { sortTasksTopologically } from "./workflowGraphUtils"; +import { useTranslation } from "react-i18next"; +import { Namespaces } from "../../../../i18n/i18n"; interface VisualiserTabProps { data: CreateWorkflowTemplateVersion; taskValidation: Record; } +type WorkflowTask = CreateWorkflowTemplateVersion["tasks"][0]; +type WorkflowEdge = { + sourceGuid: string; + targetGuid: string; + labels: string[]; + sourceIndex: number; + sourceCount: number; + targetIndex: number; + targetCount: number; +}; + +function getOutcomeId(value: string): string { + return value.split(".").pop() ?? value; +} + +function getOutcomeLinks( + task: WorkflowTask, +): Array<{ targetGuid: string; label: string }> { + const raw = task.config.outcomeActions as unknown; + + if (Array.isArray(raw)) { + return raw + .map((entry) => { + const value = entry as Partial<{ + outcome: string; + verdict: string; + task: string | null; + }>; + if (!value.task) { + return null; + } + + return { + targetGuid: value.task, + label: value.outcome ?? value.verdict ?? "", + }; + }) + .filter( + (link): link is { targetGuid: string; label: string } => + !!link && typeof link.targetGuid === "string", + ); + } + + if (raw && typeof raw === "object") { + return Object.entries(raw as Record) + .filter(([, targetGuid]) => typeof targetGuid === "string") + .map(([label, targetGuid]) => ({ + targetGuid: targetGuid as string, + label, + })); + } + + return []; +} + +function getOutcomeTargets(task: WorkflowTask): string[] { + return getOutcomeLinks(task) + .map((link) => link.targetGuid) + .filter( + (targetGuid, index, all) => + typeof targetGuid === "string" && all.indexOf(targetGuid) === index, + ); +} + +function buildLevels(tasks: WorkflowTask[]): WorkflowTask[][] { + if (tasks.length === 0) { + return []; + } + + const byGuid = new Map(); + const indexByGuid = new Map(); + const outgoing = new Map>(); + const indegree = new Map(); + const levelByGuid = new Map(); + + tasks.forEach((task, index) => { + const guid = task.config.guid as string; + byGuid.set(guid, task); + indexByGuid.set(guid, index); + outgoing.set(guid, new Set()); + indegree.set(guid, 0); + levelByGuid.set(guid, 0); + }); + + tasks.forEach((task) => { + const sourceGuid = task.config.guid as string; + const sourceTargets = outgoing.get(sourceGuid) as Set; + + getOutcomeTargets(task).forEach((targetGuid) => { + if (!byGuid.has(targetGuid) || targetGuid === sourceGuid) { + return; + } + + if (sourceTargets.has(targetGuid)) { + return; + } + + sourceTargets.add(targetGuid); + indegree.set(targetGuid, (indegree.get(targetGuid) ?? 0) + 1); + }); + }); + + const queue: string[] = []; + tasks.forEach((task) => { + const guid = task.config.guid as string; + if ((indegree.get(guid) ?? 0) === 0) { + queue.push(guid); + } + }); + + queue.sort((a, b) => (indexByGuid.get(a) ?? 0) - (indexByGuid.get(b) ?? 0)); + + const visited = new Set(); + while (queue.length > 0) { + const guid = queue.shift() as string; + visited.add(guid); + + const currentLevel = levelByGuid.get(guid) ?? 0; + (outgoing.get(guid) ?? new Set()).forEach((targetGuid) => { + levelByGuid.set( + targetGuid, + Math.max(levelByGuid.get(targetGuid) ?? 0, currentLevel + 1), + ); + + const nextInDegree = (indegree.get(targetGuid) ?? 0) - 1; + indegree.set(targetGuid, nextInDegree); + if (nextInDegree === 0) { + queue.push(targetGuid); + } + }); + + queue.sort((a, b) => (indexByGuid.get(a) ?? 0) - (indexByGuid.get(b) ?? 0)); + } + + // Cycle fallback: place unresolved tasks by original order on next available level. + if (visited.size < tasks.length) { + let maxLevel = 0; + levelByGuid.forEach((level) => { + maxLevel = Math.max(maxLevel, level); + }); + + tasks.forEach((task) => { + const guid = task.config.guid as string; + if (!visited.has(guid)) { + maxLevel += 1; + levelByGuid.set(guid, maxLevel); + } + }); + } + + const grouped = new Map(); + tasks.forEach((task) => { + const guid = task.config.guid as string; + const level = levelByGuid.get(guid) ?? 0; + const list = grouped.get(level) ?? []; + list.push(task); + grouped.set(level, list); + }); + + return Array.from(grouped.entries()) + .sort((a, b) => a[0] - b[0]) + .map((entry) => entry[1]); +} + +function buildEdges(tasks: WorkflowTask[]): WorkflowEdge[] { + const validGuids = new Set(tasks.map((task) => task.config.guid as string)); + const grouped = new Map< + string, + { sourceGuid: string; targetGuid: string; labels: Set } + >(); + + tasks.forEach((task) => { + const sourceGuid = task.config.guid as string; + + getOutcomeLinks(task).forEach(({ targetGuid, label }) => { + if (!validGuids.has(targetGuid) || targetGuid === sourceGuid) { + return; + } + + const key = `${sourceGuid}->${targetGuid}`; + const existing = grouped.get(key); + if (existing) { + if (label) { + existing.labels.add(label); + } + return; + } + + grouped.set(key, { + sourceGuid, + targetGuid, + labels: new Set(label ? [label] : []), + }); + }); + }); + + const edges = Array.from(grouped.values()).map((edge) => ({ + sourceGuid: edge.sourceGuid, + targetGuid: edge.targetGuid, + labels: Array.from(edge.labels).sort((a, b) => a.localeCompare(b)), + sourceIndex: 0, + sourceCount: 1, + targetIndex: 0, + targetCount: 1, + })); + + const bySource = new Map(); + const byTarget = new Map(); + + edges.forEach((edge) => { + const sourceList = bySource.get(edge.sourceGuid) ?? []; + sourceList.push(edge); + bySource.set(edge.sourceGuid, sourceList); + + const targetList = byTarget.get(edge.targetGuid) ?? []; + targetList.push(edge); + byTarget.set(edge.targetGuid, targetList); + }); + + bySource.forEach((sourceEdges) => { + sourceEdges.sort((a, b) => a.targetGuid.localeCompare(b.targetGuid)); + sourceEdges.forEach((edge, index) => { + edge.sourceIndex = index; + edge.sourceCount = sourceEdges.length; + }); + }); + + byTarget.forEach((targetEdges) => { + targetEdges.sort((a, b) => a.sourceGuid.localeCompare(b.sourceGuid)); + targetEdges.forEach((edge, index) => { + edge.targetIndex = index; + edge.targetCount = targetEdges.length; + }); + }); + + return edges; +} + const VisualiserTab: React.FC = ({ data, taskValidation, }) => { - const tasks = data.tasks; + const { t: tEnum } = useTranslation(Namespaces.enumValues); + const [taskMetadata, setTaskMetadata] = React.useState([]); + + const tasks = React.useMemo( + () => sortTasksTopologically(data.tasks) as WorkflowTask[], + [data.tasks], + ); + const taskByGuid = React.useMemo(() => { + const byGuid = new Map(); + tasks.forEach((task) => { + byGuid.set(task.config.guid as string, task); + }); + return byGuid; + }, [tasks]); + const metadataByTaskType = React.useMemo(() => { + const map = new Map(); + taskMetadata.forEach((meta) => { + map.set(meta.taskType, meta); + }); + return map; + }, [taskMetadata]); + + React.useEffect(() => { + let mounted = true; + + const loadTaskMetadata = async () => { + const meta = await templateVersionsService.getTaskMetadata(""); + if (mounted) { + setTaskMetadata(meta); + } + }; + + loadTaskMetadata(); + + return () => { + mounted = false; + }; + }, []); + const levels = React.useMemo(() => buildLevels(tasks), [tasks]); + const edges = React.useMemo(() => buildEdges(tasks), [tasks]); + const levelByGuid = React.useMemo(() => { + const levelMap = new Map(); + levels.forEach((level, levelIndex) => { + level.forEach((task) => { + levelMap.set(task.config.guid as string, levelIndex); + }); + }); + return levelMap; + }, [levels]); + const edgeLaneOffsetByKey = React.useMemo(() => { + const grouped = new Map(); + + edges.forEach((edge) => { + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + + let routeType = "adjacent"; + if (targetLevel - sourceLevel > 1) { + routeType = "down-skip"; + } else if (targetLevel < sourceLevel) { + routeType = "upward"; + } + + const bucketKey = `${routeType}:${Math.min(sourceLevel, targetLevel)}:${Math.max(sourceLevel, targetLevel)}`; + const list = grouped.get(bucketKey) ?? []; + list.push(edge); + grouped.set(bucketKey, list); + }); + + const offsets = new Map(); + grouped.forEach((group) => { + group + .sort((a, b) => { + const aKey = `${a.sourceGuid}-${a.targetGuid}`; + const bKey = `${b.sourceGuid}-${b.targetGuid}`; + return aKey.localeCompare(bKey); + }) + .forEach((edge, index) => { + const centeredIndex = index - (group.length - 1) / 2; + const edgeKey = `${edge.sourceGuid}->${edge.targetGuid}`; + offsets.set(edgeKey, centeredIndex * 2.2); + }); + }); + + return offsets; + }, [edges, levelByGuid]); + const visualiserContainerRef = React.useRef(null); + const [nodePositions, setNodePositions] = React.useState< + Map + >(new Map()); + const mergedLeftIncomingByTarget = React.useMemo(() => { + const incomingCount = new Map(); + + edges.forEach((edge) => { + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + if (targetLevel < sourceLevel) { + incomingCount.set( + edge.targetGuid, + (incomingCount.get(edge.targetGuid) ?? 0) + 1, + ); + } + }); + + const sharedLaneByTarget = new Map(); + + edges.forEach((edge) => { + if ((incomingCount.get(edge.targetGuid) ?? 0) <= 1) { + return; + } + + const source = nodePositions.get(edge.sourceGuid); + const target = nodePositions.get(edge.targetGuid); + if (!source || !target) { + return; + } + + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + if (targetLevel >= sourceLevel) { + return; + } + + const sourceShift = + (edge.sourceIndex - (edge.sourceCount - 1) / 2) * 2.25; + const laneSpread = + edgeLaneOffsetByKey.get(`${edge.sourceGuid}->${edge.targetGuid}`) ?? 0; + const startX = source.x - source.width / 2; + const startY = source.y + sourceShift * 0.35 + laneSpread * 0.25; + const endX = target.x - target.width / 2; + const endY = target.y; + const verticalSpan = Math.abs(endY - startY); + const longRouteExtra = + Math.max(0, verticalSpan - 10) * 0.2 + + Math.abs(targetLevel - sourceLevel) * 1.2; + const candidateLaneX = + Math.min(startX, endX) - + 6 - + Math.abs(sourceShift) + + -longRouteExtra + + laneSpread * 1.2; + + const current = sharedLaneByTarget.get(edge.targetGuid); + sharedLaneByTarget.set( + edge.targetGuid, + current === undefined + ? candidateLaneX + : Math.min(current, candidateLaneX), + ); + }); + + return sharedLaneByTarget; + }, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]); + + React.useEffect(() => { + const measurePositions = () => { + if (!visualiserContainerRef.current) { + return; + } + + const containerRect = + visualiserContainerRef.current.getBoundingClientRect(); + if (containerRect.width === 0 || containerRect.height === 0) { + return; + } + + const positions = new Map< + string, + { x: number; y: number; width: number; height: number } + >(); + + const nodes = + visualiserContainerRef.current.querySelectorAll(".visualiser-node"); + nodes.forEach((node) => { + const guid = node.getAttribute("data-guid"); + if (!guid) { + return; + } + + const nodeRect = node.getBoundingClientRect(); + const x = + ((nodeRect.left - containerRect.left + nodeRect.width / 2) / + containerRect.width) * + 100; + const y = + ((nodeRect.top - containerRect.top + nodeRect.height / 2) / + containerRect.height) * + 100; + const width = (nodeRect.width / containerRect.width) * 100; + const height = (nodeRect.height / containerRect.height) * 100; + + positions.set(guid, { x, y, width, height }); + }); + + setNodePositions(positions); + }; + + const timer = setTimeout(measurePositions, 50); + + if (!visualiserContainerRef.current) { + return () => clearTimeout(timer); + } + + const resizeObserver = new ResizeObserver(() => { + measurePositions(); + }); + + resizeObserver.observe(visualiserContainerRef.current); + + return () => { + clearTimeout(timer); + resizeObserver.disconnect(); + }; + }, [levels]); return (
-
-
-
-
- {tasks.map((task) => ( -
-
- - {(task.config.name as string) || task.type} +
+
+ {levels.map((level, levelIndex) => ( +
+
+ {level.map((task) => ( +
+
+ + {(task.config.name as string) || task.type} +
-
- ))} + ))} +
-
+ ))}
+
);