590 lines
20 KiB
TypeScript
590 lines
20 KiB
TypeScript
import React, { useRef, useState, useEffect } from "react";
|
|
import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService";
|
|
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
|
|
|
|
interface VisualiserTabProps {
|
|
data: CreateWorkflowTemplateVersion;
|
|
taskValidation: Record<string, boolean>;
|
|
}
|
|
|
|
interface ArrowPositions {
|
|
levelIndex: number;
|
|
positions: number[];
|
|
}
|
|
|
|
const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|
data,
|
|
taskValidation,
|
|
}) => {
|
|
const tasks = data.tasks;
|
|
const levels: CreateWorkflowTemplateVersion["tasks"][] = [];
|
|
const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]);
|
|
const taskColumnMap = useRef<Map<string, number>>(new Map());
|
|
const visualiserContainerRef = useRef<HTMLDivElement>(null);
|
|
const [taskDOMPositions, setTaskDOMPositions] = useState<
|
|
Map<string, { x: number; y: number }>
|
|
>(new Map());
|
|
|
|
// Calculate column range directly from taskColumnMap in render
|
|
const allColumns = Array.from(taskColumnMap.current.values());
|
|
const columnRange = {
|
|
min: allColumns.length > 0 ? Math.min(...allColumns) : 0,
|
|
max: allColumns.length > 0 ? Math.max(...allColumns) : 0,
|
|
};
|
|
|
|
if (tasks.length > 0) {
|
|
const byGuid = new Map<string, CreateWorkflowTemplateVersion["tasks"][0]>();
|
|
tasks.forEach((task) => {
|
|
byGuid.set(task.config.guid as string, task);
|
|
});
|
|
|
|
const levelByGuid = new Map<string, number>();
|
|
const columnByGuid = new Map<string, number>();
|
|
|
|
const getLevel = (task: CreateWorkflowTemplateVersion["tasks"][0]) => {
|
|
const guid = task.config.guid as string;
|
|
const cached = levelByGuid.get(guid);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
const predecessors = task.config.predecessors as string[] | undefined;
|
|
if (!predecessors || predecessors.length === 0) {
|
|
levelByGuid.set(guid, 1);
|
|
return 1;
|
|
}
|
|
|
|
const predecessorLevels = predecessors.map((pred) => {
|
|
const predTask = byGuid.get(pred);
|
|
if (!predTask) {
|
|
return 1;
|
|
}
|
|
return getLevel(predTask);
|
|
});
|
|
|
|
const nextLevel = Math.max(...predecessorLevels, 0) + 1;
|
|
levelByGuid.set(guid, nextLevel);
|
|
return nextLevel;
|
|
};
|
|
|
|
// First pass: calculate levels for all tasks
|
|
tasks.forEach((task) => {
|
|
getLevel(task);
|
|
});
|
|
|
|
// Second pass: assign columns based on predecessor-child relationships
|
|
const getColumn = (
|
|
task: CreateWorkflowTemplateVersion["tasks"][0],
|
|
): number => {
|
|
const guid = task.config.guid as string;
|
|
const cached = columnByGuid.get(guid);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
const predecessors = task.config.predecessors as string[] | undefined;
|
|
if (!predecessors || predecessors.length === 0) {
|
|
columnByGuid.set(guid, 0);
|
|
return 0;
|
|
}
|
|
|
|
// Get predecessor column and find which child index this is
|
|
const predTask = byGuid.get(predecessors[0]);
|
|
if (!predTask) {
|
|
columnByGuid.set(guid, 0);
|
|
return 0;
|
|
}
|
|
|
|
const predColumn = getColumn(predTask);
|
|
|
|
// Find all siblings (tasks with same predecessor)
|
|
const siblings = tasks.filter((t) => {
|
|
const preds = t.config.predecessors as string[] | undefined;
|
|
return preds && preds[0] === predecessors[0];
|
|
});
|
|
|
|
// Sort siblings by name for consistent ordering
|
|
siblings.sort((a, b) => {
|
|
const nameA = (a.config.name as string) || a.type || "";
|
|
const nameB = (b.config.name as string) || b.type || "";
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
const siblingIndex = siblings.findIndex(
|
|
(s) => (s.config.guid as string) === guid,
|
|
);
|
|
|
|
// Assign columns: spread siblings around parent column
|
|
const startCol = Math.max(
|
|
0,
|
|
predColumn - Math.floor(siblings.length / 2),
|
|
);
|
|
const column = startCol + siblingIndex;
|
|
|
|
columnByGuid.set(guid, column);
|
|
return column;
|
|
};
|
|
|
|
// Calculate columns for all tasks
|
|
tasks.forEach((task) => {
|
|
const col = getColumn(task);
|
|
taskColumnMap.current.set(task.config.guid as string, col);
|
|
});
|
|
|
|
const levelsMap = new Map<number, CreateWorkflowTemplateVersion["tasks"]>();
|
|
tasks.forEach((task) => {
|
|
const level = getLevel(task);
|
|
const list = levelsMap.get(level) ?? [];
|
|
list.push(task);
|
|
levelsMap.set(level, list);
|
|
});
|
|
|
|
// Sort tasks within each level by column
|
|
[...levelsMap.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.forEach((entry) => {
|
|
entry[1].sort((a, b) => {
|
|
const colA = taskColumnMap.current.get(a.config.guid as string) ?? 0;
|
|
const colB = taskColumnMap.current.get(b.config.guid as string) ?? 0;
|
|
return colA - colB;
|
|
});
|
|
levels.push(entry[1]);
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
// Measure actual positions of task nodes and calculate arrow positions
|
|
const positions: ArrowPositions[] = [];
|
|
|
|
levelRowRefs.current.forEach((levelRow, levelIndex) => {
|
|
if (!levelRow) return;
|
|
|
|
const levelRect = levelRow.getBoundingClientRect();
|
|
const nodes = levelRow.querySelectorAll(".visualiser-node");
|
|
const nodePositions: number[] = [];
|
|
|
|
nodes.forEach((node) => {
|
|
const nodeRect = node.getBoundingClientRect();
|
|
// Calculate center of node relative to level row
|
|
const relativeCenter =
|
|
(nodeRect.left - levelRect.left + nodeRect.width / 2) /
|
|
levelRect.width;
|
|
// Convert to SVG coordinates (0-100)
|
|
nodePositions.push(relativeCenter * 100);
|
|
});
|
|
|
|
if (nodePositions.length > 0) {
|
|
positions.push({ levelIndex, positions: nodePositions });
|
|
}
|
|
});
|
|
|
|
setArrowPositions(positions);
|
|
}, [tasks]);
|
|
|
|
useEffect(() => {
|
|
// Measure task DOM positions for outcome connectors
|
|
if (!visualiserContainerRef.current) return;
|
|
|
|
const containerRect =
|
|
visualiserContainerRef.current.getBoundingClientRect();
|
|
const positions = new Map<string, { x: number; y: number }>();
|
|
|
|
const nodes =
|
|
visualiserContainerRef.current.querySelectorAll(".visualiser-node");
|
|
nodes.forEach((node) => {
|
|
const taskName = node.querySelector(".visualiser-node-content span");
|
|
if (!taskName) return;
|
|
|
|
// Find task by name
|
|
const task = tasks.find(
|
|
(t) => (t.config.name as string) === taskName.textContent,
|
|
);
|
|
if (!task) return;
|
|
|
|
const nodeRect = node.getBoundingClientRect();
|
|
const relX =
|
|
((nodeRect.left - containerRect.left + nodeRect.width / 2) /
|
|
containerRect.width) *
|
|
100;
|
|
const relY =
|
|
((nodeRect.top - containerRect.top + nodeRect.height / 2) /
|
|
containerRect.height) *
|
|
100;
|
|
|
|
positions.set(task.config.guid as string, { x: relX, y: relY });
|
|
});
|
|
|
|
setTaskDOMPositions(positions);
|
|
}, [tasks, levels]);
|
|
|
|
const getArrowPositionsForLevel = (levelIndex: number): number[] => {
|
|
const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex);
|
|
return found?.positions ?? [];
|
|
};
|
|
|
|
// Calculate connector positions based on grid columns
|
|
const getConnectorPositionsByColumn = (
|
|
levelTasks: CreateWorkflowTemplateVersion["tasks"][],
|
|
): number[] => {
|
|
const numColumns = columnRange.max - columnRange.min + 1;
|
|
if (numColumns <= 1) return [50];
|
|
|
|
return levelTasks.map((task) => {
|
|
const taskColumn =
|
|
taskColumnMap.current.get(task.config.guid as string) ?? 0;
|
|
const gridCol = taskColumn - columnRange.min;
|
|
// Map grid column to percentage (e.g., 2 columns: col 0 = 25%, col 1 = 75%)
|
|
return ((gridCol + 0.5) / numColumns) * 100;
|
|
});
|
|
};
|
|
|
|
const renderConnector = (
|
|
count: number,
|
|
positions?: number[],
|
|
predecessorPositions?: number[],
|
|
) => {
|
|
if (count <= 1) {
|
|
// For a single child, use predecessor position if available
|
|
let startX = 50;
|
|
let endX = 50;
|
|
|
|
// If single child with predecessor positions, connect from the appropriate predecessor
|
|
if (positions && positions.length === 1) {
|
|
endX = positions[0];
|
|
|
|
if (predecessorPositions && predecessorPositions.length > 0) {
|
|
// Find the closest predecessor position to the child position
|
|
const closest = predecessorPositions.reduce((prev, curr) =>
|
|
Math.abs(curr - endX) < Math.abs(prev - endX) ? curr : prev,
|
|
);
|
|
startX = closest;
|
|
}
|
|
}
|
|
|
|
// If start and end are the same, use simple vertical connector
|
|
if (!predecessorPositions || predecessorPositions.length === 0) {
|
|
return (
|
|
<div aria-hidden="true" className="visualiser-connector">
|
|
<div className="visualiser-connector-line" />
|
|
<div className="visualiser-connector-arrow" />
|
|
<div className="visualiser-connector-tail" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Otherwise render SVG with line from start to end (always, even if vertical)
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
className="visualiser-connector-branch"
|
|
viewBox="0 0 100 40"
|
|
preserveAspectRatio="none"
|
|
>
|
|
<line
|
|
className="visualiser-connector-branch-line"
|
|
x1={startX}
|
|
y1="0"
|
|
x2={endX}
|
|
y2="40"
|
|
/>
|
|
<polygon
|
|
className="visualiser-connector-branch-arrow"
|
|
points={`${endX - 1.5},34 ${endX + 1.5},34 ${endX},40`}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// Use measured positions if available and valid, otherwise calculate evenly distributed
|
|
const displayPositions =
|
|
positions && positions.length > 0
|
|
? positions
|
|
: Array.from(
|
|
{ length: count },
|
|
(_, index) => ((index + 1) / (count + 1)) * 100,
|
|
);
|
|
|
|
// Guard against empty or invalid positions
|
|
if (!displayPositions || displayPositions.length === 0) {
|
|
return (
|
|
<div aria-hidden="true" className="visualiser-connector">
|
|
<div className="visualiser-connector-line" />
|
|
<div className="visualiser-connector-arrow" />
|
|
<div className="visualiser-connector-tail" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const minX = Math.min(...displayPositions);
|
|
const maxX = Math.max(...displayPositions);
|
|
|
|
// Final safety check for Infinity
|
|
if (!isFinite(minX) || !isFinite(maxX)) {
|
|
return (
|
|
<div aria-hidden="true" className="visualiser-connector">
|
|
<div className="visualiser-connector-line" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
className="visualiser-connector-branch"
|
|
viewBox="0 0 100 40"
|
|
preserveAspectRatio="none"
|
|
>
|
|
<line
|
|
className="visualiser-connector-branch-line"
|
|
x1="50"
|
|
y1="0"
|
|
x2="50"
|
|
y2="14"
|
|
/>
|
|
<line
|
|
className="visualiser-connector-branch-line"
|
|
x1={minX}
|
|
y1="14"
|
|
x2={maxX}
|
|
y2="14"
|
|
/>
|
|
{displayPositions.map((x) => (
|
|
<g key={x}>
|
|
<line
|
|
className="visualiser-connector-branch-line"
|
|
x1={x}
|
|
y1="14"
|
|
x2={x}
|
|
y2="28"
|
|
/>
|
|
<polygon
|
|
className="visualiser-connector-branch-arrow"
|
|
points={`${x - 1.5},28 ${x + 1.5},28 ${x},34`}
|
|
/>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// Collect all outcome-based connections
|
|
const getOutcomeConnections = (): Array<{
|
|
sourceGuid: string;
|
|
targetGuid: string;
|
|
verdict: string;
|
|
sourceX: number;
|
|
targetX: number;
|
|
sourceY: number;
|
|
targetY: number;
|
|
}> => {
|
|
const connections: Array<{
|
|
sourceGuid: string;
|
|
targetGuid: string;
|
|
verdict: string;
|
|
sourceX: number;
|
|
targetX: number;
|
|
sourceY: number;
|
|
targetY: number;
|
|
}> = [];
|
|
|
|
tasks.forEach((task) => {
|
|
const outcomes =
|
|
(task.config.outcomeActions as Array<{
|
|
verdict: string;
|
|
task: string | null;
|
|
}>) ?? [];
|
|
|
|
outcomes.forEach((outcome) => {
|
|
if (outcome.task) {
|
|
const sourcePos = taskDOMPositions.get(task.config.guid as string);
|
|
const targetPos = taskDOMPositions.get(outcome.task);
|
|
|
|
if (sourcePos && targetPos) {
|
|
connections.push({
|
|
sourceGuid: task.config.guid as string,
|
|
targetGuid: outcome.task,
|
|
verdict: outcome.verdict,
|
|
sourceX: sourcePos.x,
|
|
sourceY: sourcePos.y,
|
|
targetX: targetPos.x,
|
|
targetY: targetPos.y,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
return connections;
|
|
};
|
|
|
|
return (
|
|
<div className="visualiser-root">
|
|
<div className="visualiser-container" ref={visualiserContainerRef}>
|
|
<div className="visualiser-flow">
|
|
<div className="visualiser-node">
|
|
<div className="visualiser-node-content">
|
|
<span>Start</span>
|
|
</div>
|
|
</div>
|
|
{levels.length > 0 &&
|
|
renderConnector(
|
|
levels[0].length,
|
|
getConnectorPositionsByColumn(levels[0]),
|
|
undefined,
|
|
)}
|
|
{levels.map((level, index) => (
|
|
<React.Fragment key={`level-${index}`}>
|
|
<div className="visualiser-level">
|
|
<div
|
|
className="visualiser-level-row"
|
|
ref={(el) => {
|
|
levelRowRefs.current[index] = el;
|
|
}}
|
|
style={
|
|
{
|
|
gridTemplateColumns:
|
|
columnRange.max > columnRange.min
|
|
? `repeat(${
|
|
columnRange.max - columnRange.min + 1
|
|
}, minmax(220px, 1fr))`
|
|
: "220px",
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
{level.map((task) => {
|
|
const taskColumn =
|
|
taskColumnMap.current.get(task.config.guid as string) ??
|
|
0;
|
|
const isRootTask = !(
|
|
task.config.predecessors as string[] | undefined
|
|
)?.length;
|
|
const numColumns = columnRange.max - columnRange.min + 1;
|
|
|
|
let styleObj: React.CSSProperties = {};
|
|
|
|
if (isRootTask && numColumns > 1) {
|
|
// Root tasks span all columns and center their content
|
|
styleObj = {
|
|
gridColumn: `1 / -1`,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
};
|
|
} else {
|
|
// Child tasks positioned in their specific column
|
|
const gridColumn = taskColumn - columnRange.min + 1;
|
|
styleObj = { gridColumn };
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={task.config.guid as string}
|
|
className="visualiser-node"
|
|
data-column={taskColumn}
|
|
style={styleObj}
|
|
>
|
|
<div className="visualiser-node-content">
|
|
<ValidationErrorIcon
|
|
visible={
|
|
taskValidation[task.config.guid as string] ===
|
|
false
|
|
}
|
|
/>
|
|
<span>
|
|
{(task.config.name as string) || task.type}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{index < levels.length - 1
|
|
? renderConnector(
|
|
levels[index + 1].length,
|
|
getConnectorPositionsByColumn(levels[index + 1]),
|
|
getConnectorPositionsByColumn(levels[index]),
|
|
)
|
|
: renderConnector(1, undefined, undefined)}
|
|
</React.Fragment>
|
|
))}
|
|
{levels.length === 0 && renderConnector(1)}
|
|
<div className="visualiser-node">
|
|
<div className="visualiser-node-content">
|
|
<span>End</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Outcome-based loop-back connectors overlay */}
|
|
<svg
|
|
aria-hidden="true"
|
|
className="visualiser-outcome-connectors"
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
>
|
|
{getOutcomeConnections().map((conn, idx) => {
|
|
const controlOffsetX = 20;
|
|
const controlOffsetY =
|
|
(Math.abs(conn.targetY - conn.sourceY) / 2) * 0.3;
|
|
|
|
// Calculate text position at the curve's midpoint (t=0.5 on Bezier curve)
|
|
const t = 0.5;
|
|
const p0 = { x: conn.sourceX, y: conn.sourceY };
|
|
const p1 = {
|
|
x: conn.sourceX - controlOffsetX,
|
|
y: conn.sourceY + controlOffsetY,
|
|
};
|
|
const p2 = {
|
|
x: conn.targetX - controlOffsetX,
|
|
y: conn.targetY - controlOffsetY,
|
|
};
|
|
const p3 = { x: conn.targetX, y: conn.targetY };
|
|
|
|
// Cubic Bezier curve formula at t
|
|
const textX =
|
|
Math.pow(1 - t, 3) * p0.x +
|
|
3 * Math.pow(1 - t, 2) * t * p1.x +
|
|
3 * (1 - t) * Math.pow(t, 2) * p2.x +
|
|
Math.pow(t, 3) * p3.x;
|
|
const textY =
|
|
Math.pow(1 - t, 3) * p0.y +
|
|
3 * Math.pow(1 - t, 2) * t * p1.y +
|
|
3 * (1 - t) * Math.pow(t, 2) * p2.y +
|
|
Math.pow(t, 3) * p3.y;
|
|
|
|
return (
|
|
<g
|
|
key={`outcome-${idx}`}
|
|
className="visualiser-outcome-connector"
|
|
>
|
|
<path
|
|
className="visualiser-outcome-connector-line"
|
|
d={`M ${conn.sourceX} ${conn.sourceY}
|
|
C ${conn.sourceX - controlOffsetX} ${conn.sourceY + controlOffsetY},
|
|
${conn.targetX - controlOffsetX} ${conn.targetY - controlOffsetY},
|
|
${conn.targetX} ${conn.targetY}`}
|
|
/>
|
|
<polygon
|
|
className="visualiser-outcome-connector-arrow"
|
|
points={`${conn.targetX - 1},${conn.targetY - 3} ${conn.targetX + 1},${conn.targetY - 3} ${conn.targetX},${conn.targetY}`}
|
|
/>
|
|
<text
|
|
x={textX}
|
|
y={textY}
|
|
className="visualiser-outcome-connector-label"
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
>
|
|
{conn.verdict}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VisualiserTab;
|