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