Files
ordo-designer-vue/src/mapper/graph.ts
T

293 lines
8.8 KiB
TypeScript

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
}