feat: bootstrap Ordo process designer with PARALLEL swimlanes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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] }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user