293 lines
8.8 KiB
TypeScript
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
|
|
}
|