feat: bootstrap Ordo process designer with PARALLEL swimlanes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-18 18:19:10 +08:00
co-authored by Cursor
commit 2c2dd8d0d2
46 changed files with 7801 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
export type DurationUnit = 'minutes' | 'hours' | 'days'
export function parseIsoDuration(iso: string | undefined): { value: number; unit: DurationUnit } {
const raw = iso?.trim() ?? ''
const day = /^P(\d+)D$/i.exec(raw)
if (day) {
return { value: Number(day[1]), unit: 'days' }
}
const hour = /^PT(\d+)H$/i.exec(raw)
if (hour) {
return { value: Number(hour[1]), unit: 'hours' }
}
const min = /^PT(\d+)M$/i.exec(raw)
if (min) {
return { value: Number(min[1]), unit: 'minutes' }
}
return { value: 48, unit: 'hours' }
}
export function toIsoDuration(value: number, unit: DurationUnit): string {
const n = Math.max(1, Math.floor(value) || 1)
if (unit === 'days') {
return `P${n}D`
}
if (unit === 'hours') {
return `PT${n}H`
}
return `PT${n}M`
}
+227
View File
@@ -0,0 +1,227 @@
export type HandleSide = 'top' | 'bottom' | 'left' | 'right'
export interface ObstacleRect {
x: number
y: number
width: number
height: number
}
export interface OrthogonalPathParams {
sourceX: number
sourceY: number
targetX: number
targetY: number
sourceSide?: HandleSide
targetSide?: HandleSide
/** Stagger when several edges share a corridor. */
corridorOffset?: number
obstacles?: ObstacleRect[]
}
export type OrthogonalPathResult = {
path: string
labelX: number
labelY: number
}
export function sideFromHandleId(handleId?: string | null): HandleSide | undefined {
if (!handleId) {
return undefined
}
if (handleId.endsWith('-top') || handleId === 'top') {
return 'top'
}
if (handleId.endsWith('-bottom') || handleId === 'bottom') {
return 'bottom'
}
if (handleId.endsWith('-left') || handleId === 'left') {
return 'left'
}
if (handleId.endsWith('-right') || handleId === 'right') {
return 'right'
}
return undefined
}
function isHorizontal(side: HandleSide) {
return side === 'left' || side === 'right'
}
const NODE_CLEAR = 56
function hitsVertical(x: number, y0: number, y1: number, rects: ObstacleRect[], margin = 0) {
const top = Math.min(y0, y1) + 3
const bot = Math.max(y0, y1) - 3
if (bot <= top) {
return false
}
return rects.some(
(r) =>
x > r.x - margin &&
x < r.x + r.width + margin &&
bot > r.y &&
top < r.y + r.height,
)
}
function hitsHorizontal(y: number, x0: number, x1: number, rects: ObstacleRect[], margin = 0) {
const left = Math.min(x0, x1) + 3
const right = Math.max(x0, x1) - 3
if (right <= left) {
return false
}
return rects.some(
(r) =>
y > r.y - margin &&
y < r.y + r.height + margin &&
right > r.x &&
left < r.x + r.width,
)
}
function routeBottomTopAround(
sx: number,
sy: number,
tx: number,
ty: number,
corridor: number,
obstacles: ObstacleRect[],
): OrthogonalPathResult {
const preferRight = tx >= sx
const blocked = obstacles.filter(
(r) => hitsVertical(sx, sy, ty, [r]) || hitsVertical(tx, sy, ty, [r]),
)
const basis = blocked.length ? blocked : obstacles
let lane = preferRight
? Math.max(sx, ...basis.map((r) => r.x + r.width)) + NODE_CLEAR
: Math.min(sx, ...basis.map((r) => r.x)) - NODE_CLEAR
lane += preferRight ? corridor : -corridor
const y0 = Math.min(sy, ty)
const y1Span = Math.max(sy, ty)
for (let i = 0; i < 16 && hitsVertical(lane, y0, y1Span, obstacles, NODE_CLEAR); i += 1) {
lane += preferRight ? 18 : -18
}
const below = basis.map((r) => r.y).filter((y) => y > sy + 4)
const firstTop = below.length ? Math.min(...below) : sy + 64
const yTurn = Math.max(sy + 14, Math.min(sy + 32, firstTop - 16))
let yEnter = ty - 28
if (hitsHorizontal(yEnter, lane, tx, obstacles)) {
yEnter = ty - 14
}
if (yEnter < yTurn + 16) {
yEnter = yTurn + 16
}
return {
path: `M ${sx},${sy} L ${sx},${yTurn} L ${lane},${yTurn} L ${lane},${yEnter} L ${tx},${yEnter} L ${tx},${ty}`,
labelX: (sx + lane) / 2,
labelY: yTurn,
}
}
export function getOrthogonalPath(params: OrthogonalPathParams): OrthogonalPathResult {
const sx = params.sourceX
const sy = params.sourceY
const tx = params.targetX
const ty = params.targetY
const corridor = params.corridorOffset ?? 0
const sourceSide = params.sourceSide ?? (ty + 8 < sy ? 'left' : 'bottom')
const targetSide = params.targetSide ?? (ty + 8 < sy ? 'left' : 'top')
if (Math.abs(sx - tx) < 2 && Math.abs(sy - ty) < 2) {
return { path: `M ${sx},${sy}`, labelX: sx, labelY: sy }
}
// Side → side (back-edge / loop): out horizontally, vertical, in horizontally.
if (isHorizontal(sourceSide) && isHorizontal(targetSide)) {
const pad = 48 + Math.abs(corridor)
const outLeft = sourceSide === 'left'
const inLeft = targetSide === 'left'
let hx1 = outLeft ? sx - pad : sx + pad
let hx2 = inLeft ? tx - pad : tx + pad
if (outLeft === inLeft) {
const shared = outLeft ? Math.min(sx, tx) - pad : Math.max(sx, tx) + pad
hx1 = shared + corridor
hx2 = hx1
return {
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${ty} L ${tx},${ty}`,
labelX: hx1,
labelY: (sy + ty) / 2,
}
}
hx1 += corridor
hx2 += corridor
const midY = (sy + ty) / 2
return {
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${midY} L ${hx2},${midY} L ${hx2},${ty} L ${tx},${ty}`,
labelX: (hx1 + hx2) / 2,
labelY: midY,
}
}
// Bottom → Top (default downward flow).
if (sourceSide === 'bottom' && targetSide === 'top') {
const obstacles = params.obstacles ?? []
if (Math.abs(sx - tx) < 2) {
if (!hitsVertical(sx, sy, ty, obstacles)) {
return {
path: `M ${sx},${sy} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: (sy + ty) / 2,
}
}
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
}
const dy = ty - sy
const minDrop = 36
let midY = sy + Math.min(Math.max(dy / 2, minDrop), Math.max(minDrop, dy - minDrop))
midY += corridor
const lo = sy + 20
const hi = ty - 20
if (hi > lo) {
midY = Math.min(hi, Math.max(lo, midY))
}
const blocked =
hitsVertical(sx, sy, midY, obstacles) ||
hitsHorizontal(midY, sx, tx, obstacles) ||
hitsVertical(tx, midY, ty, obstacles)
if (blocked) {
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
}
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Bottom → side
if (sourceSide === 'bottom' && isHorizontal(targetSide)) {
const midY = sy + 36 + corridor
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Side → top
if (isHorizontal(sourceSide) && targetSide === 'top') {
const midY = ty - 36 - Math.abs(corridor)
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Fallback: simple elbow
const midY = (sy + ty) / 2 + corridor
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
+292
View File
@@ -0,0 +1,292 @@
import type {
ActionNodeData,
ApprovalNodeData,
DesignerEdgeData,
DesignerNodeData,
ProcessDefinitionDocument,
StepDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel'
export const APPROVAL_TYPE = 'approval'
export const ACTION_TYPE = 'action'
export const PARALLEL_TYPE = 'parallel'
export const END_TYPE = 'end'
export const END_NODE_ID = '__end__'
export interface DesignerNode {
id: string
type: string
position: { x: number; y: number }
data: DesignerNodeData
parentNode?: string
extent?: 'parent'
style?: { width: string; height: string }
zIndex?: number
sourceHandle?: string
targetHandle?: string
}
export interface DesignerEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
label?: string
data: DesignerEdgeData
}
export function edgeLabel(data: DesignerEdgeData | undefined): string {
const when = formatWhen(data?.when)
return when || 'default'
}
export function toDocument(
id: string,
name: string,
startStepId: string,
nodes: DesignerNode[],
edges: DesignerEdge[],
): ProcessDefinitionDocument {
const top = nodes.filter((node) => node.type !== END_TYPE && !node.parentNode)
const start = top.find((node) => node.id === startStepId) ?? top[0]
const rest = top.filter((node) => node.id !== start?.id)
const ordered = start ? [start, ...rest] : rest
const steps: StepDocument[] = ordered.map((node) => {
const data = node.data
if (data?.kind === 'ACTION') {
return { id: node.id, name: data.name, kind: 'ACTION', action: data.action }
}
if (data?.kind === 'PARALLEL') {
return {
id: node.id,
name: data.name,
kind: 'PARALLEL',
branches: branchesOf(node.id, data.lanes, nodes, edges),
}
}
return approvalStepDocument(data as ApprovalNodeData, node.id)
})
const transitions = edges
.filter((edge) => {
const source = nodes.find((node) => node.id === edge.source)
return source && !source.parentNode
})
.map((edge) => {
const target = nodes.find((node) => node.id === edge.target)
const data = edge.data ?? { priority: 0 }
return {
from: edge.source,
to: target?.type === END_TYPE || target?.parentNode ? null : edge.target,
...(data.when ? { when: data.when } : {}),
priority: data.priority ?? 0,
}
})
return {
id,
name,
startStep: start?.id,
steps,
transitions,
}
}
export function fromDocument(
doc: ProcessDefinitionDocument,
layout: Record<string, { x: number; y: number }> = {},
): { nodes: DesignerNode[]; edges: DesignerEdge[]; startStepId: string } {
const nodes: DesignerNode[] = []
const innerEdges: DesignerEdge[] = []
doc.steps.forEach((step, index) => {
const position = layout[step.id] ?? { x: 220, y: 40 + index * 140 }
if (step.kind === 'PARALLEL') {
const built = materializeParallel(step.id, step.name, position, step.branches ?? [])
nodes.push(built.parent, ...built.children)
innerEdges.push(...built.edges)
return
}
if (step.kind === 'ACTION') {
const data: ActionNodeData = {
...defaultActionData(),
name: step.name,
action: step.action ?? '',
isStart: false,
}
nodes.push({ id: step.id, type: ACTION_TYPE, position, data })
return
}
const data: ApprovalNodeData = {
...defaultApprovalData(),
name: step.name,
candidates: step.candidates ?? [],
policy: step.policy ?? 'ANY',
dueEnabled: Boolean(step.due),
due: step.due
? {
after: step.due.after,
then: step.due.then,
to: step.due.to ?? undefined,
action: step.due.action ?? undefined,
}
: { after: 'PT48H', then: 'notify' },
isStart: false,
}
nodes.push({ id: step.id, type: APPROVAL_TYPE, position, data })
})
const hasEnd = doc.transitions.some((t) => t.to == null)
if (hasEnd) {
nodes.push({
id: END_NODE_ID,
type: END_TYPE,
position: layout[END_NODE_ID] ?? { x: 220, y: 40 + doc.steps.length * 140 },
data: defaultEndData(),
})
}
const startStepId = doc.startStep ?? doc.steps[0]?.id ?? ''
for (const node of nodes) {
if (node.data && node.data.kind !== 'END') {
node.data.isStart = node.id === startStepId
}
}
const edges: DesignerEdge[] = [
...innerEdges,
...doc.transitions.map((t, i) => {
const data: DesignerEdgeData = {
when: t.when,
priority: t.priority ?? 0,
}
return {
id: `e-${i}-${t.from}-${t.to ?? 'end'}`,
source: t.from,
target: t.to ?? END_NODE_ID,
label: edgeLabel(data),
data,
}
}),
]
return { nodes, edges, startStepId }
}
export function layoutFromNodes(nodes: DesignerNode[]): Record<string, { x: number; y: number }> {
const layout: Record<string, { x: number; y: number }> = {}
for (const node of nodes) {
layout[node.id] = { x: node.position.x, y: node.position.y }
}
return layout
}
export interface GraphError {
message: string
nodeId?: string
edgeId?: string
}
export function comparableDocument(doc: ProcessDefinitionDocument): string {
return JSON.stringify(
{
id: doc.id,
name: doc.name,
startStep: doc.startStep,
steps: doc.steps,
transitions: doc.transitions,
},
null,
2,
)
}
export function validateGraph(
id: string,
name: string,
startStepId: string,
nodes: DesignerNode[],
edges: DesignerEdge[],
): GraphError[] {
const errors: GraphError[] = []
if (!id.trim()) {
errors.push({ message: 'Process id is required' })
}
if (!name.trim()) {
errors.push({ message: 'Process name is required' })
}
const stepNodes = nodes.filter((n) => n.type !== END_TYPE)
if (stepNodes.length === 0) {
errors.push({ message: 'At least one step is required' })
}
const ids = new Set<string>()
for (const node of stepNodes) {
if (ids.has(node.id)) {
errors.push({ message: `Duplicate step id: ${node.id}`, nodeId: node.id })
}
ids.add(node.id)
const data = node.data
if (data?.kind === 'APPROVAL' && data.candidates.length === 0) {
errors.push({ message: `Approval step ${node.id} needs candidates`, nodeId: node.id })
}
if (data?.kind === 'ACTION' && !data.action.trim()) {
errors.push({ message: `ACTION step ${node.id} needs action`, nodeId: node.id })
}
if (data?.kind === 'PARALLEL') {
if (data.lanes.length < 2) {
errors.push({ message: `PARALLEL ${node.id} needs at least two branches`, nodeId: node.id })
}
const seenLanes = new Set<string>()
for (const lane of data.lanes) {
if (!lane.trim() || seenLanes.has(lane)) {
errors.push({ message: `PARALLEL ${node.id} has a bad branch id: ${lane}`, nodeId: node.id })
}
seenLanes.add(lane)
if (!nodes.some((child) => child.parentNode === node.id && laneOf(child.data) === lane)) {
errors.push({ message: `Branch ${lane} needs a step`, nodeId: node.id })
}
}
}
const outgoing = edges.filter((e) => e.source === node.id)
if (!node.parentNode && outgoing.length === 0) {
errors.push({ message: `Step ${node.id} has no outgoing edge`, nodeId: node.id })
}
}
if (startStepId && !ids.has(startStepId)) {
errors.push({ message: `Unknown startStep: ${startStepId}` })
}
if (nodes.some((node) => node.id === startStepId && node.parentNode)) {
errors.push({ message: 'startStep must not be inside a parallel branch' })
}
const seenPriority = new Set<string>()
for (const edge of edges) {
const source = nodes.find((n) => n.id === edge.source)
const target = nodes.find((n) => n.id === edge.target)
if (!target) {
errors.push({ message: `Edge target missing: ${edge.target}`, edgeId: edge.id })
} else if (target.type !== END_TYPE && !ids.has(edge.target)) {
errors.push({ message: `Edge target is not a step: ${edge.target}`, edgeId: edge.id })
}
if (!source || !ids.has(edge.source)) {
errors.push({ message: `Edge source is not a step: ${edge.source}`, edgeId: edge.id })
}
if (source?.parentNode || target?.parentNode) {
if (source?.parentNode !== target?.parentNode || laneOf(source?.data) !== laneOf(target?.data)) {
errors.push({ message: `Branch edge must stay in the same branch`, edgeId: edge.id })
}
}
const key = `${edge.source}:${edge.data?.priority ?? 0}`
if (seenPriority.has(key)) {
errors.push({
message: `Duplicate outgoing priority: ${edge.source} / ${edge.data?.priority ?? 0}`,
edgeId: edge.id,
})
}
seenPriority.add(key)
}
return errors
}
+265
View File
@@ -0,0 +1,265 @@
import { END_TYPE, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
import { fitParallelFrames } from '@/mapper/parallel'
const RANK_Y = 220
const NODE_GAP_X = 320
const ORIGIN_X = 80
const ORIGIN_Y = 40
function buildOutgoing(edges: DesignerEdge[]) {
const outgoing = new Map<string, DesignerEdge[]>()
for (const edge of edges) {
const list = outgoing.get(edge.source) ?? []
list.push(edge)
outgoing.set(edge.source, list)
}
for (const list of outgoing.values()) {
list.sort((a, b) => (a.data?.priority ?? 0) - (b.data?.priority ?? 0) || a.target.localeCompare(b.target))
}
return outgoing
}
/** Edges that close a cycle (feedback arcs); excluded from rank / column inheritance. */
function findBackEdgeIds(nodeIds: string[], edges: DesignerEdge[], startId?: string): Set<string> {
const ids = new Set(nodeIds)
const back = new Set<string>()
const visited = new Set<string>()
const stack = new Set<string>()
const outgoing = buildOutgoing(edges)
function dfs(u: string) {
visited.add(u)
stack.add(u)
for (const edge of outgoing.get(u) ?? []) {
if (!ids.has(edge.target)) {
continue
}
if (stack.has(edge.target)) {
back.add(edge.id)
continue
}
if (!visited.has(edge.target)) {
dfs(edge.target)
}
}
stack.delete(u)
}
const roots = startId && ids.has(startId) ? [startId] : nodeIds
for (const root of roots) {
if (!visited.has(root)) {
dfs(root)
}
}
for (const id of nodeIds) {
if (!visited.has(id)) {
dfs(id)
}
}
return back
}
/** Longest-path ranks on the DAG after removing feedback arcs. */
function computeRanks(
nodeIds: string[],
edges: DesignerEdge[],
backEdgeIds: Set<string>,
): Map<string, number> {
const rank = new Map<string, number>()
for (const id of nodeIds) {
rank.set(id, 0)
}
const forward = edges.filter((e) => !backEdgeIds.has(e.id))
let changed = true
let guard = 0
while (changed && guard < nodeIds.length + 4) {
changed = false
guard += 1
for (const edge of forward) {
if (!rank.has(edge.source) || !rank.has(edge.target)) {
continue
}
const next = (rank.get(edge.source) ?? 0) + 1
if (next > (rank.get(edge.target) ?? 0)) {
rank.set(edge.target, next)
changed = true
}
}
}
return rank
}
function assignColumns(
nodeIds: string[],
startId: string | undefined,
rank: Map<string, number>,
outgoing: Map<string, DesignerEdge[]>,
backEdgeIds: Set<string>,
): Map<string, number> {
const col = new Map<string, number>()
let nextCol = 0
const ordered = [...nodeIds].sort(
(a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || a.localeCompare(b),
)
if (startId && rank.has(startId)) {
col.set(startId, 0)
nextCol = 1
}
for (const id of ordered) {
if (!col.has(id)) {
col.set(id, nextCol)
nextCol += 1
}
const parentCol = col.get(id) ?? 0
let inherited = false
const seen = new Set<string>()
for (const edge of outgoing.get(id) ?? []) {
if (backEdgeIds.has(edge.id) || seen.has(edge.target) || !rank.has(edge.target)) {
continue
}
seen.add(edge.target)
if (col.has(edge.target)) {
continue
}
if (!inherited) {
col.set(edge.target, parentCol)
inherited = true
} else {
col.set(edge.target, nextCol)
nextCol += 1
}
}
}
for (const id of nodeIds) {
if (!col.has(id)) {
col.set(id, nextCol)
nextCol += 1
}
}
return col
}
/** Map legacy dual side-handle ids onto the single left/right handles. */
export function normalizeHandleId(handleId?: string | null): string | undefined {
if (!handleId) {
return undefined
}
if (handleId === 'source-left' || handleId === 'target-left' || handleId === 'left') {
return 'left'
}
if (handleId === 'source-right' || handleId === 'target-right' || handleId === 'right') {
return 'right'
}
if (handleId === 'source-bottom' || handleId === 'bottom') {
return 'source-bottom'
}
if (handleId === 'target-top' || handleId === 'top') {
return 'target-top'
}
return handleId
}
/** Pick handles from node positions only (avoids rank ties marking every edge as a back-edge). */
export function pickHandlesForEdge(
source: DesignerNode,
target: DesignerNode,
): { sourceHandle: string; targetHandle: string } {
const dy = target.position.y - source.position.y
const dx = target.position.x - source.position.x
const down = 64
const up = -64
if (dy >= down) {
return { sourceHandle: 'source-bottom', targetHandle: 'target-top' }
}
if (dy <= up) {
const side = dx > 0 ? 'right' : 'left'
return { sourceHandle: side, targetHandle: side }
}
// Same row: exit toward the target, enter from the facing side.
if (dx >= 0) {
return { sourceHandle: 'right', targetHandle: 'left' }
}
return { sourceHandle: 'left', targetHandle: 'right' }
}
/** Recompute every edge's handles from current node positions. */
export function assignEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
const byId = new Map(nodes.map((n) => [n.id, n]))
return edges.map((edge) => {
const source = byId.get(edge.source)
const target = byId.get(edge.target)
if (!source || !target) {
return {
...edge,
sourceHandle: normalizeHandleId(edge.sourceHandle) ?? 'source-bottom',
targetHandle: normalizeHandleId(edge.targetHandle) ?? 'target-top',
}
}
return { ...edge, ...pickHandlesForEdge(source, target) }
})
}
/** @deprecated use assignEdgeHandles — kept name for draft reopen path */
export function fillMissingEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
return assignEdgeHandles(nodes, edges)
}
export function applyLayeredLayout(
nodes: DesignerNode[],
edges: DesignerEdge[],
startStepId: string,
): { nodes: DesignerNode[]; edges: DesignerEdge[] } {
const parked = nodes.filter((node) => node.parentNode)
if (parked.length > 0) {
const top = nodes.filter((node) => !node.parentNode)
const topIds = new Set(top.map((node) => node.id))
const topEdges = edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target))
const laid = applyLayeredLayout(top, topEdges, startStepId)
const merged = fitParallelFrames([...laid.nodes, ...parked])
return { nodes: merged, edges: assignEdgeHandles(merged, edges) }
}
if (nodes.length === 0) {
return { nodes, edges }
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const nodeIds = nodes.map((n) => n.id)
const outgoing = buildOutgoing(edges)
const start = byId.has(startStepId)
? startStepId
: (nodes.find((n) => n.type !== END_TYPE)?.id ?? nodes[0]?.id)
const backEdgeIds = findBackEdgeIds(nodeIds, edges, start)
const rank = computeRanks(nodeIds, edges, backEdgeIds)
if (start && (rank.get(start) ?? 0) > 0) {
const shift = rank.get(start) ?? 0
for (const id of nodeIds) {
rank.set(id, Math.max(0, (rank.get(id) ?? 0) - shift))
}
}
const col = assignColumns(nodeIds, start, rank, outgoing, backEdgeIds)
const laidOut = nodes.map((node) => {
const r = rank.get(node.id) ?? 0
const c = col.get(node.id) ?? 0
return {
...node,
position: {
x: ORIGIN_X + c * NODE_GAP_X,
y: ORIGIN_Y + r * RANK_Y,
},
}
})
return {
nodes: laidOut,
edges: assignEdgeHandles(laidOut, edges),
}
}
export { RANK_Y, NODE_GAP_X }
+327
View File
@@ -0,0 +1,327 @@
import type {
ActionNodeData,
ApprovalNodeData,
BranchDocument,
DesignerNodeData,
ParallelNodeData,
StepDocument,
TransitionDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import type { RoutingWhen } from '@/mapper/when'
export interface CanvasNode {
id: string
type: string
position: { x: number; y: number }
data: DesignerNodeData
parentNode?: string
extent?: 'parent'
style?: { width: string; height: string }
zIndex?: number
}
export interface CanvasEdge {
id: string
source: string
target: string
label?: string
data: { when?: RoutingWhen; priority: number }
}
export const LANE_HEADER = 44
export const LANE_H = 128
export const LANE_LABEL_W = 76
export const LANE_STEP_W = 220
export const LANE_STEP_GAP = 28
export function laneOf(data: DesignerNodeData | undefined): string | undefined {
if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) {
return undefined
}
return data.laneId
}
export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } {
return {
width: LANE_LABEL_W + 16 + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) + 12,
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + 10,
}
}
export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } {
return {
x: LANE_LABEL_W + 8 + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
y: LANE_HEADER + laneIndex * LANE_H + 16,
}
}
export function fitParallelFrames(nodes: CanvasNode[]): CanvasNode[] {
return nodes.map((node) => {
if (node.data?.kind !== 'PARALLEL') {
return node
}
const lanes = node.data.lanes
const children = nodes.filter((child) => child.parentNode === node.id)
const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length))
const size = parallelSize(lanes.length, maxSteps)
return {
...node,
zIndex: 0,
style: { width: `${size.width}px`, height: `${size.height}px` },
}
})
}
export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
const next = nodes.map((node) => {
if (!node.parentNode) {
return node
}
const parent = nodes.find((item) => item.id === node.parentNode)
if (!parent || parent.data?.kind !== 'PARALLEL') {
return node
}
const lanes = parent.data.lanes
const laneIndex = Math.min(
lanes.length - 1,
Math.max(0, Math.floor((node.position.y - LANE_HEADER) / LANE_H)),
)
const lane = lanes[laneIndex] ?? lanes[0]
if (!lane) {
return node
}
const data = node.data
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
return node
}
return {
...node,
zIndex: 1,
data: { ...data, laneId: lane },
position: {
x: Math.max(LANE_LABEL_W, node.position.x),
y: LANE_HEADER + laneIndex * LANE_H + 16,
},
}
})
return fitParallelFrames(next)
}
export function approvalStepDocument(data: ApprovalNodeData, id: string): StepDocument {
const step: StepDocument = {
id,
name: data.name,
kind: 'APPROVAL',
candidates: data.candidates,
policy: data.policy,
}
if (data.dueEnabled && data.due.after && data.due.then) {
step.due = {
after: data.due.after,
then: data.due.then,
...(data.due.to ? { to: data.due.to } : {}),
...(data.due.action ? { action: data.due.action } : {}),
}
}
return step
}
export function branchesOf(
parallelId: string,
lanes: string[],
nodes: CanvasNode[],
edges: CanvasEdge[],
): BranchDocument[] {
return lanes.map((lane) => {
const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
const ids = new Set(steps.map((step) => step.id))
const transitions: TransitionDocument[] = edges
.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
.map((edge) => ({
from: edge.source,
to: edge.target,
...(edge.data.when ? { when: edge.data.when } : {}),
priority: edge.data.priority ?? 0,
}))
for (const step of steps) {
if (!edges.some((edge) => edge.source === step.id && ids.has(edge.target))) {
transitions.push({ from: step.id, to: null, priority: 0 })
}
}
return {
id: lane,
steps: steps.map((node) => {
if (node.data.kind === 'ACTION') {
return { id: node.id, name: node.data.name, kind: 'ACTION' as const, action: node.data.action }
}
return approvalStepDocument(node.data as ApprovalNodeData, node.id)
}),
transitions,
}
})
}
export function spawnLaneStep(
parallelId: string,
laneId: string,
laneIndex: number,
stepIndex: number,
id: string,
): CanvasNode {
const data: ApprovalNodeData = { ...defaultApprovalData(), name: 'Approval', laneId }
return {
id,
type: 'approval',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
export function materializeParallel(
id: string,
name: string,
position: { x: number; y: number },
branches: BranchDocument[],
isStart = false,
): { parent: CanvasNode; children: CanvasNode[]; edges: CanvasEdge[] } {
const lanes = branches.map((branch) => branch.id)
const data: ParallelNodeData = { kind: 'PARALLEL', name, lanes, isStart }
const maxSteps = Math.max(1, ...branches.map((branch) => branch.steps.length))
const size = parallelSize(lanes.length, maxSteps)
const parent: CanvasNode = {
id,
type: 'parallel',
position,
zIndex: 0,
style: { width: `${size.width}px`, height: `${size.height}px` },
data,
}
const children: CanvasNode[] = []
const edges: CanvasEdge[] = []
branches.forEach((branch, laneIndex) => {
branch.steps.forEach((step, stepIndex) => {
children.push(stepNode(step, id, branch.id, laneIndex, stepIndex))
})
branch.transitions.forEach((transition, index) => {
if (!transition.to) {
return
}
const data = { when: transition.when, priority: transition.priority ?? 0 }
edges.push({
id: `e-${id}-${branch.id}-${index}`,
source: transition.from,
target: transition.to,
label: formatWhen(transition.when) || 'default',
data,
})
})
})
return { parent, children, edges }
}
function stepNode(
step: StepDocument,
parallelId: string,
laneId: string,
laneIndex: number,
stepIndex: number,
): CanvasNode {
if (step.kind === 'ACTION') {
const data: ActionNodeData = { ...defaultActionData(), name: step.name, action: step.action ?? '', laneId }
return {
id: step.id,
type: 'action',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
const data: ApprovalNodeData = {
...defaultApprovalData(),
name: step.name,
candidates: step.candidates ?? [],
policy: step.policy ?? 'ANY',
dueEnabled: Boolean(step.due),
due: step.due
? { after: step.due.after, then: step.due.then, to: step.due.to ?? undefined, action: step.due.action ?? undefined }
: { after: 'PT48H', then: 'notify' },
laneId,
}
return {
id: step.id,
type: 'approval',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
export function dropCrossLaneEdges(nodes: CanvasNode[], edges: CanvasEdge[]): CanvasEdge[] {
const byId = new Map(nodes.map((node) => [node.id, node]))
return edges.filter((edge) => {
const source = byId.get(edge.source)
const target = byId.get(edge.target)
if (!source?.parentNode && !target?.parentNode) {
return true
}
if (!source?.parentNode || !target?.parentNode) {
return false
}
return source.parentNode === target.parentNode && laneOf(source.data) === laneOf(target.data)
})
}
export function reflowLanes(nodes: CanvasNode[], parallelId: string): CanvasNode[] {
const parent = nodes.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL') {
return nodes
}
const placed = new Map<string, CanvasNode>()
parent.data.lanes.forEach((lane, laneIndex) => {
const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
steps.forEach((step, stepIndex) => {
const data = step.data
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
return
}
placed.set(step.id, {
...step,
position: laneStepPosition(laneIndex, stepIndex),
data: { ...data, laneId: lane },
})
})
})
return fitParallelFrames(nodes.map((node) => placed.get(node.id) ?? node))
}
export function expandLegacyParallel(
nodes: CanvasNode[],
edges: CanvasEdge[],
): { nodes: CanvasNode[]; edges: CanvasEdge[] } {
const next: CanvasNode[] = []
const extra: CanvasEdge[] = []
for (const node of nodes) {
const data = node.data as ParallelNodeData & { branches?: BranchDocument[] }
if (data?.kind === 'PARALLEL' && data.branches?.length && !data.lanes) {
const built = materializeParallel(node.id, data.name, node.position, data.branches, Boolean(data.isStart))
next.push(built.parent, ...built.children)
extra.push(...built.edges)
continue
}
next.push(node)
}
return { nodes: next, edges: [...extra, ...edges] }
}
+216
View File
@@ -0,0 +1,216 @@
export type CompareOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in'
export type RoutingWhen =
| { ref: string; args?: Record<string, unknown> }
| { and: RoutingWhen[] }
| { or: RoutingWhen[] }
| { not: RoutingWhen }
| { eq: [string, unknown] }
| { ne: [string, unknown] }
| { gt: [string, unknown] }
| { gte: [string, unknown] }
| { lt: [string, unknown] }
| { lte: [string, unknown] }
| { in: [string, unknown[]] }
const COMPARE_OPS: CompareOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
export function isRefWhen(when: RoutingWhen | undefined): when is { ref: string; args?: Record<string, unknown> } {
return Boolean(when && typeof when === 'object' && 'ref' in when)
}
export function formatWhen(when: RoutingWhen | undefined): string {
if (!when) {
return ''
}
if (isRefWhen(when)) {
const args = when.args
if (!args || Object.keys(args).length === 0) {
return when.ref
}
const body = Object.entries(args)
.map(([k, v]) => `${k}=${String(v)}`)
.join(',')
return `${when.ref}(${body})`
}
if ('and' in when) {
return when.and.map(formatWhen).join(' AND ')
}
if ('or' in when) {
return when.or.map((part) => `(${formatWhen(part)})`).join(' OR ')
}
if ('not' in when) {
return `NOT (${formatWhen(when.not)})`
}
if ('in' in when) {
return `${when.in[0]} in [${when.in[1].map(String).join(', ')}]`
}
for (const op of COMPARE_OPS) {
if (op === 'in') {
continue
}
if (op in when) {
const pair = (when as Record<string, [string, unknown]>)[op]
const symbol =
op === 'eq' ? '=' : op === 'ne' ? '!=' : op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
return `${pair?.[0]} ${symbol} ${String(pair?.[1])}`
}
}
return ''
}
export function defaultLeaf(): RoutingWhen {
return { gt: ['', 0] }
}
export const MAX_PREDICATE_DEPTH = 8
export const MAX_PREDICATE_LEAVES = 32
export function isLogicGroup(
when: RoutingWhen,
): when is { and: RoutingWhen[] } | { or: RoutingWhen[] } {
return 'and' in when || 'or' in when
}
export function isNotWhen(when: RoutingWhen): when is { not: RoutingWhen } {
return 'not' in when
}
export function groupChildren(when: RoutingWhen): RoutingWhen[] {
if ('and' in when) {
return when.and
}
if ('or' in when) {
return when.or
}
return []
}
export function leafOp(when: RoutingWhen): CompareOp {
for (const op of COMPARE_OPS) {
if (op in when) {
return op
}
}
return 'gt'
}
export function leafVar(when: RoutingWhen): string {
const pair = (when as Record<string, [string, unknown]>)[leafOp(when)]
return pair?.[0] ?? ''
}
export function leafValueText(when: RoutingWhen): string {
const op = leafOp(when)
const pair = (when as Record<string, [string, unknown]>)[op]
const value = pair?.[1]
if (op === 'in' && Array.isArray(value)) {
return value.map(String).join(', ')
}
return value == null ? '' : String(value)
}
export function makeLeaf(op: CompareOp, variable: string, text: string): RoutingWhen {
if (op === 'in') {
const values = text
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map(coerceScalar)
return { in: [variable, values] }
}
return { [op]: [variable, coerceScalar(text)] } as RoutingWhen
}
function coerceScalar(text: string): unknown {
if (text === 'true') {
return true
}
if (text === 'false') {
return false
}
if (text !== '' && !Number.isNaN(Number(text))) {
return Number(text)
}
return text
}
export function countLeaves(when: RoutingWhen | undefined): number {
if (!when || isRefWhen(when)) {
return 0
}
if (isLogicGroup(when)) {
return groupChildren(when).reduce((sum, child) => sum + countLeaves(child), 0)
}
if (isNotWhen(when)) {
return countLeaves(when.not)
}
return 1
}
export function predicateDepth(when: RoutingWhen | undefined): number {
if (!when || isRefWhen(when)) {
return 0
}
if (isLogicGroup(when)) {
return 1 + groupChildren(when).reduce((max, child) => Math.max(max, predicateDepth(child)), 0)
}
if (isNotWhen(when)) {
return 1 + predicateDepth(when.not)
}
return 1
}
export function validatePredicate(when: RoutingWhen | undefined): string | null {
if (!when || isRefWhen(when)) {
return 'Add at least one condition'
}
if (predicateDepth(when) > MAX_PREDICATE_DEPTH) {
return `Nesting cannot exceed ${MAX_PREDICATE_DEPTH} levels`
}
if (countLeaves(when) > MAX_PREDICATE_LEAVES) {
return `At most ${MAX_PREDICATE_LEAVES} conditions`
}
return validateNode(when)
}
function validateNode(when: RoutingWhen): string | null {
if (isLogicGroup(when)) {
const children = groupChildren(when)
if (children.length === 0) {
return 'A group needs at least one condition'
}
for (const child of children) {
const error = validateNode(child)
if (error) {
return error
}
}
return null
}
if (isNotWhen(when)) {
return validateNode(when.not)
}
if (!leafVar(when).trim()) {
return 'Variable is required'
}
if (leafOp(when) === 'in' && leafValueText(when).trim() === '') {
return 'in requires at least one value, comma-separated'
}
return null
}
/** Single-child AND/OR is equivalent to its child; keep published JSON flat. */
export function collapseTrivialGroups(when: RoutingWhen): RoutingWhen {
if (isLogicGroup(when)) {
const children = groupChildren(when).map(collapseTrivialGroups)
if (children.length === 1) {
return children[0] ?? defaultLeaf()
}
return 'and' in when ? { and: children } : { or: children }
}
if (isNotWhen(when)) {
return { not: collapseTrivialGroups(when.not) }
}
return when
}