@stonecrop/node-editor 0.13.14 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node-editor.css +1 -1
- package/dist/node-editor.d.ts +2 -7
- package/dist/node-editor.js +5256 -3436
- package/dist/node-editor.js.map +1 -1
- package/dist/src/types/index.d.ts +2 -6
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/utils/autoLayout.d.ts +32 -0
- package/dist/src/utils/autoLayout.d.ts.map +1 -0
- package/dist/src/utils/autoLayout.js +50 -0
- package/dist/src/utils/stateTransforms.d.ts +5 -4
- package/dist/src/utils/stateTransforms.d.ts.map +1 -1
- package/dist/src/utils/stateTransforms.js +125 -81
- package/package.json +25 -22
- package/src/components/EditableEdge.vue +1 -1
- package/src/components/NodeEditor.vue +62 -11
- package/src/components/SelfLoopEdge.vue +88 -0
- package/src/components/StateEditor.vue +13 -6
- package/src/types/index.ts +4 -9
- package/src/utils/autoLayout.ts +67 -0
- package/src/utils/stateTransforms.ts +129 -82
- package/dist/index.js +0 -12
- package/dist/node-editor.umd.cjs +0 -27
- package/dist/node-editor.umd.cjs.map +0 -1
- package/dist/src/tsdoc-metadata.json +0 -11
- package/dist/types/index.js +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- transparent ghost path gives a ~20px click zone around the 2px visible stroke -->
|
|
3
|
+
<path :d="path.d" fill="none" stroke="transparent" stroke-width="20" class="vue-flow__edge-interaction" />
|
|
4
|
+
<path :id="id" :style="style" class="vue-flow__edge-path" :d="path.d" :marker-end="markerEnd" />
|
|
5
|
+
|
|
6
|
+
<EdgeLabelRenderer>
|
|
7
|
+
<div
|
|
8
|
+
:style="{
|
|
9
|
+
pointerEvents: 'all',
|
|
10
|
+
position: 'absolute',
|
|
11
|
+
transform: `translate(-50%, -50%) translate(${path.labelX}px,${path.labelY}px)`,
|
|
12
|
+
}"
|
|
13
|
+
class="nodrag nopan editable-edge-label"
|
|
14
|
+
@click="labelOnClick()"
|
|
15
|
+
@contextmenu.prevent="$emit('remove', id)">
|
|
16
|
+
<div class="vue-flow__edge-label">{{ label }}</div>
|
|
17
|
+
<div v-if="showInput" class="label-input-wrapper">
|
|
18
|
+
<input
|
|
19
|
+
ref="labelInput"
|
|
20
|
+
v-model="newLabel"
|
|
21
|
+
class="label-input"
|
|
22
|
+
@blur="showInput = false"
|
|
23
|
+
@keypress.enter="submitNewLabel" />
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</EdgeLabelRenderer>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { type EdgeProps, EdgeLabelRenderer } from '@vue-flow/core'
|
|
31
|
+
import { computed, ref, nextTick, useTemplateRef } from 'vue'
|
|
32
|
+
|
|
33
|
+
const props = defineProps<EdgeProps>()
|
|
34
|
+
const emit = defineEmits(['change', 'remove'])
|
|
35
|
+
|
|
36
|
+
// A self-loop connects a node's source handle to its own target handle. getBezierPath draws a nearly
|
|
37
|
+
// straight segment across the node for that (the two handles are on the same box), so it's useless
|
|
38
|
+
// here — we hand-author an arc that leaves the source handle, loops up and over the node top, and
|
|
39
|
+
// returns to the target handle. LOOP_HEIGHT is how far above the handles the arc bulges.
|
|
40
|
+
const LOOP_HEIGHT = 90
|
|
41
|
+
const LOOP_SPREAD = 45
|
|
42
|
+
|
|
43
|
+
const path = computed(() => {
|
|
44
|
+
const { sourceX, sourceY, targetX, targetY } = props
|
|
45
|
+
const d = `M ${sourceX},${sourceY} C ${sourceX + LOOP_SPREAD},${sourceY - LOOP_HEIGHT} ${
|
|
46
|
+
targetX - LOOP_SPREAD
|
|
47
|
+
},${targetY - LOOP_HEIGHT} ${targetX},${targetY}`
|
|
48
|
+
// Place the label near the arc's apex (a cubic bezier peaks around 0.75 of the control offset).
|
|
49
|
+
const labelX = (sourceX + targetX) / 2
|
|
50
|
+
const labelY = Math.min(sourceY, targetY) - LOOP_HEIGHT * 0.72
|
|
51
|
+
return { d, labelX, labelY }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const inputRef = useTemplateRef<HTMLInputElement>('labelInput')
|
|
55
|
+
const newLabel = ref<EdgeProps['label']>('')
|
|
56
|
+
const showInput = ref(false)
|
|
57
|
+
let lastClick = 0
|
|
58
|
+
|
|
59
|
+
const labelOnClick = async () => {
|
|
60
|
+
let now = Date.now()
|
|
61
|
+
if (now - lastClick < 500 && !showInput.value) {
|
|
62
|
+
await showLabelInput()
|
|
63
|
+
}
|
|
64
|
+
lastClick = now
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const showLabelInput = async () => {
|
|
68
|
+
newLabel.value = props.label
|
|
69
|
+
showInput.value = true
|
|
70
|
+
await nextTick()
|
|
71
|
+
inputRef.value?.focus()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const submitNewLabel = () => {
|
|
75
|
+
showInput.value = false
|
|
76
|
+
emit('change', newLabel.value)
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<script lang="ts">
|
|
81
|
+
export default {
|
|
82
|
+
inheritAttrs: false,
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
/* Label + input styles are shared with EditableEdge (defined there, global scope). */
|
|
88
|
+
</style>
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
<script setup lang="ts">
|
|
8
8
|
import { type HTMLAttributes, computed, onMounted } from 'vue'
|
|
9
|
+
import type { WorkflowMeta } from '@stonecrop/schema'
|
|
9
10
|
|
|
10
11
|
import NodeEditor from './NodeEditor.vue'
|
|
11
|
-
import type {
|
|
12
|
+
import type { FlowElements, Layout } from '../types'
|
|
13
|
+
import { autoLayout } from '../utils/autoLayout'
|
|
12
14
|
import { statesToFlowElements, flowElementsToStates } from '../utils/stateTransforms'
|
|
13
15
|
|
|
14
|
-
const
|
|
16
|
+
const workflow = defineModel<WorkflowMeta>()
|
|
15
17
|
const layout = defineModel<Layout>('layout')
|
|
16
18
|
const { nodeContainerClass = '' } = defineProps<{
|
|
17
19
|
nodeContainerClass?: HTMLAttributes['class']
|
|
@@ -25,12 +27,17 @@ onMounted(() => {
|
|
|
25
27
|
|
|
26
28
|
const elements = computed<FlowElements>({
|
|
27
29
|
get: () => {
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
+
if (!workflow.value) return []
|
|
31
|
+
const els = statesToFlowElements(workflow.value, layout.value)
|
|
32
|
+
// Seed an un-arranged workflow with an auto-computed dagre layout instead of the naive row.
|
|
33
|
+
// Ephemeral: these positions are persisted only once the author drags a node (which routes
|
|
34
|
+
// through flowElementsToStates → layout). A workflow with any saved layout keeps it as-is.
|
|
35
|
+
const noSavedLayout = !layout.value || Object.keys(layout.value).length === 0
|
|
36
|
+
return noSavedLayout ? autoLayout(els) : els
|
|
30
37
|
},
|
|
31
38
|
set: newValue => {
|
|
32
|
-
const {
|
|
33
|
-
|
|
39
|
+
const { workflow: nextWorkflow, layout: nextLayout } = flowElementsToStates(newValue, workflow.value)
|
|
40
|
+
workflow.value = nextWorkflow
|
|
34
41
|
if (layout.value !== undefined) {
|
|
35
42
|
layout.value = nextLayout
|
|
36
43
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core'
|
|
2
|
-
import type { AnyStateNodeConfig } from 'xstate'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Editor states
|
|
6
|
-
* @public
|
|
7
|
-
*/
|
|
8
|
-
export type EditorStates = Record<string, AnyStateNodeConfig>
|
|
9
2
|
|
|
10
3
|
/**
|
|
11
4
|
* Flow elements
|
|
@@ -13,7 +6,9 @@ export type EditorStates = Record<string, AnyStateNodeConfig>
|
|
|
13
6
|
*/
|
|
14
7
|
export type FlowElements = Elements<
|
|
15
8
|
{ hasInput?: boolean; hasOutput?: boolean },
|
|
16
|
-
|
|
9
|
+
// `actionKey` is the stable action identity carried on an edge, decoupled from its visible
|
|
10
|
+
// `label` (the display name). It lets an edge be relabeled without re-keying the action.
|
|
11
|
+
{ hasInput?: boolean; hasOutput?: boolean; actionKey?: string }
|
|
17
12
|
>
|
|
18
13
|
|
|
19
14
|
/**
|
|
@@ -22,7 +17,7 @@ export type FlowElements = Elements<
|
|
|
22
17
|
*/
|
|
23
18
|
export type FlowElement = Element<
|
|
24
19
|
{ hasInput?: boolean; hasOutput?: boolean },
|
|
25
|
-
{ hasInput?: boolean; hasOutput?: boolean }
|
|
20
|
+
{ hasInput?: boolean; hasOutput?: boolean; actionKey?: string }
|
|
26
21
|
>
|
|
27
22
|
|
|
28
23
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Graph, layout } from '@dagrejs/dagre'
|
|
2
|
+
|
|
3
|
+
import type { FlowElements } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for {@link autoLayout}.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export interface AutoLayoutOptions {
|
|
10
|
+
/** Layout direction. Defaults to `'LR'` to match the editor's Left/Right handle defaults. */
|
|
11
|
+
rankdir?: 'LR' | 'RL' | 'TB' | 'BT'
|
|
12
|
+
/** Fallback node width when no measured size is supplied (the seed path has no DOM to measure). */
|
|
13
|
+
nodeWidth?: number
|
|
14
|
+
/** Fallback node height when no measured size is supplied. */
|
|
15
|
+
nodeHeight?: number
|
|
16
|
+
/** Measured per-node sizes (node id → box) — supplied by the on-demand "Auto-arrange" button. */
|
|
17
|
+
dimensions?: Record<string, { width: number; height: number }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute node positions for a workflow graph with dagre's layered (Sugiyama) algorithm.
|
|
22
|
+
*
|
|
23
|
+
* Pure and synchronous: takes VueFlow elements (nodes + edges) and returns a new elements array with
|
|
24
|
+
* each node's `position` replaced by a laid-out coordinate; edges and every other node field are
|
|
25
|
+
* passed through untouched. Used two ways — as the seed for an un-arranged workflow (fixed default
|
|
26
|
+
* dimensions, no DOM) and behind the toolbar button (measured `dimensions`).
|
|
27
|
+
*
|
|
28
|
+
* Self-loop edges (`source === target`) are skipped: dagre doesn't rank them, and they're reserved
|
|
29
|
+
* for the separate mutate-in-place self-transition feature.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export function autoLayout(elements: FlowElements, options: AutoLayoutOptions = {}): FlowElements {
|
|
33
|
+
const { rankdir = 'LR', nodeWidth = 160, nodeHeight = 40, dimensions } = options
|
|
34
|
+
const sizeOf = (id: string) => dimensions?.[id] ?? { width: nodeWidth, height: nodeHeight }
|
|
35
|
+
|
|
36
|
+
// Nodes are the elements without a `source` (the same discriminator flowElementsToStates uses).
|
|
37
|
+
const nodeIds: string[] = []
|
|
38
|
+
for (const el of elements) {
|
|
39
|
+
if (!('source' in el)) nodeIds.push(el.id)
|
|
40
|
+
}
|
|
41
|
+
if (nodeIds.length === 0) return [...elements]
|
|
42
|
+
|
|
43
|
+
const graph = new Graph()
|
|
44
|
+
graph.setGraph({ rankdir, nodesep: 40, ranksep: 80, edgesep: 20 })
|
|
45
|
+
graph.setDefaultEdgeLabel(() => ({}))
|
|
46
|
+
|
|
47
|
+
for (const id of nodeIds) {
|
|
48
|
+
const { width, height } = sizeOf(id)
|
|
49
|
+
graph.setNode(id, { width, height })
|
|
50
|
+
}
|
|
51
|
+
for (const el of elements) {
|
|
52
|
+
if (!('source' in el)) continue
|
|
53
|
+
if (el.source === el.target) continue
|
|
54
|
+
graph.setEdge(el.source, el.target)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
layout(graph)
|
|
58
|
+
|
|
59
|
+
// dagre reports each node by its CENTER; VueFlow positions nodes by their TOP-LEFT corner.
|
|
60
|
+
return elements.map(el => {
|
|
61
|
+
if ('source' in el) return el
|
|
62
|
+
const center = graph.node(el.id)
|
|
63
|
+
if (!center) return el
|
|
64
|
+
const { width, height } = sizeOf(el.id)
|
|
65
|
+
return { ...el, position: { x: center.x - width / 2, y: center.y - height / 2 } }
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -1,117 +1,164 @@
|
|
|
1
1
|
import { Position, type Node } from '@vue-flow/core'
|
|
2
|
+
import type { ActionDefinition, WorkflowMeta } from '@stonecrop/schema'
|
|
2
3
|
|
|
3
|
-
import type {
|
|
4
|
+
import type { FlowElements, Layout } from '../types'
|
|
4
5
|
|
|
5
|
-
export function statesToFlowElements(
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
6
|
+
export function statesToFlowElements(workflow: WorkflowMeta, layout?: Layout): FlowElements {
|
|
7
|
+
const { states = [], actions = {} } = workflow
|
|
8
|
+
const edges: FlowElements = []
|
|
9
|
+
const nodes: Node[] = []
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
id: key,
|
|
14
|
-
label: key,
|
|
15
|
-
position: layout?.[key]?.position ?? { x: 200 * index, y: 100 },
|
|
16
|
-
targetPosition: layout?.[key]?.targetPosition ?? Position.Left,
|
|
17
|
-
sourcePosition: layout?.[key]?.sourcePosition ?? Position.Right,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (value.type === 'final') {
|
|
21
|
-
el.type = 'output'
|
|
22
|
-
el.class = 'default-output-node'
|
|
23
|
-
}
|
|
11
|
+
for (const [actionKey, actionDef] of Object.entries(actions)) {
|
|
12
|
+
// Stateless commands (print/email) have no graph presence; anything graph-owned needs states.
|
|
13
|
+
if (actionDef.stateless || !actionDef.allowedStates?.length) continue
|
|
24
14
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
: edgeValue != null &&
|
|
36
|
-
typeof edgeValue === 'object' &&
|
|
37
|
-
'target' in edgeValue &&
|
|
38
|
-
typeof edgeValue.target === 'string'
|
|
39
|
-
? edgeValue.target
|
|
40
|
-
: ''
|
|
41
|
-
stateElements.push({
|
|
42
|
-
id: `${key}-${target}`,
|
|
43
|
-
source: key,
|
|
44
|
-
target,
|
|
45
|
-
label: edgeKey,
|
|
15
|
+
// A self-transition (mutate-in-place `save`) renders as a self-loop on each allowed state:
|
|
16
|
+
// `source === target`, tagged `selfloop` so NodeEditor routes it to the arc renderer.
|
|
17
|
+
if (actionDef.selfTransition) {
|
|
18
|
+
for (const source of actionDef.allowedStates) {
|
|
19
|
+
edges.push({
|
|
20
|
+
id: `${actionKey}-${source}`,
|
|
21
|
+
source,
|
|
22
|
+
target: source,
|
|
23
|
+
label: actionDef.label ?? actionKey,
|
|
24
|
+
data: { actionKey },
|
|
46
25
|
animated: true,
|
|
47
|
-
type: '
|
|
26
|
+
type: 'selfloop',
|
|
48
27
|
interactionWidth: 40,
|
|
49
28
|
})
|
|
50
|
-
hasInputs[target] = true
|
|
51
29
|
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A cross-state transition needs a target; a malformed action with neither is skipped.
|
|
34
|
+
if (!actionDef.nextState) continue
|
|
35
|
+
for (const source of actionDef.allowedStates) {
|
|
36
|
+
edges.push({
|
|
37
|
+
id: `${actionKey}-${source}`,
|
|
38
|
+
source,
|
|
39
|
+
target: actionDef.nextState,
|
|
40
|
+
// The edge paints the human display label; its identity (the action key) rides in
|
|
41
|
+
// `data.actionKey`, so relabeling the edge renames the action without re-keying it.
|
|
42
|
+
label: actionDef.label ?? actionKey,
|
|
43
|
+
data: { actionKey },
|
|
44
|
+
animated: true,
|
|
45
|
+
type: 'smoothstep',
|
|
46
|
+
interactionWidth: 40,
|
|
47
|
+
})
|
|
52
48
|
}
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
for (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
for (let index = 0; index < states.length; index++) {
|
|
52
|
+
const state = states[index]
|
|
53
|
+
const node: Node = {
|
|
54
|
+
id: state,
|
|
55
|
+
label: state,
|
|
56
|
+
position: layout?.[state]?.position ?? { x: 200 * index, y: 100 },
|
|
57
|
+
targetPosition: layout?.[state]?.targetPosition ?? Position.Left,
|
|
58
|
+
sourcePosition: layout?.[state]?.sourcePosition ?? Position.Right,
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// Every state renders identically — both handles, no start-state styling — so any transition
|
|
61
|
+
// (including a returning `reject`/`reopen` into the initial state) is authorable and a
|
|
62
|
+
// self-loop can anchor both ends. Marking an entry state is deferred (YAGNI) until needed.
|
|
63
|
+
nodes.push(node)
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
return
|
|
66
|
+
return [...edges, ...nodes]
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
export function flowElementsToStates(
|
|
67
|
-
|
|
69
|
+
export function flowElementsToStates(
|
|
70
|
+
nextElements: FlowElements,
|
|
71
|
+
existingWorkflow?: WorkflowMeta
|
|
72
|
+
): { workflow: WorkflowMeta; layout: Layout } {
|
|
68
73
|
const idToLabel: Record<string, string> = {}
|
|
69
|
-
const nodeTypeByLabel: Record<string, string | undefined> = {}
|
|
70
74
|
const nextLayout: Layout = {}
|
|
75
|
+
const stateNames: string[] = []
|
|
71
76
|
|
|
72
|
-
// First pass: build id→label map from nodes so edge targets resolve correctly
|
|
73
77
|
for (const el of nextElements) {
|
|
74
|
-
if (
|
|
75
|
-
|
|
78
|
+
if ('source' in el) continue
|
|
79
|
+
const label = typeof el.label === 'string' ? el.label : el.id
|
|
80
|
+
idToLabel[el.id] = label
|
|
81
|
+
stateNames.push(label)
|
|
82
|
+
if (el.position) {
|
|
83
|
+
nextLayout[label] = {
|
|
84
|
+
position: el.position,
|
|
85
|
+
...(el.targetPosition !== undefined && { targetPosition: el.targetPosition }),
|
|
86
|
+
...(el.sourcePosition !== undefined && { sourcePosition: el.sourcePosition }),
|
|
87
|
+
}
|
|
76
88
|
}
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
//
|
|
91
|
+
// Group directed edges by their stable action key. A round-tripped edge carries the key in
|
|
92
|
+
// `data.actionKey`; a freshly-drawn edge (no data yet) falls back to its label as the key — the
|
|
93
|
+
// one moment label and key coincide, when the action is first born. The edge's label is captured
|
|
94
|
+
// separately as the display name, so a later relabel renames the action without re-keying it.
|
|
95
|
+
const transitionGroups: Record<
|
|
96
|
+
string,
|
|
97
|
+
{ nextState?: string; allowedStates: string[]; label: string; selfLoop: boolean }
|
|
98
|
+
> = {}
|
|
80
99
|
for (const el of nextElements) {
|
|
81
|
-
if ('source' in el
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
if (!('source' in el)) continue
|
|
101
|
+
const edgeLabel = typeof el.label === 'string' ? el.label : el.id
|
|
102
|
+
const actionKey = el.data?.actionKey ?? edgeLabel
|
|
103
|
+
const sourceLabel = idToLabel[el.source] || el.source
|
|
104
|
+
const targetLabel = idToLabel[el.target] || el.target
|
|
105
|
+
// Classify by topology, not by edge `type`: a self-loop (source === target) round-trips to a
|
|
106
|
+
// self-transition regardless of how it was authored (drawn node→itself, or re-rendered from a
|
|
107
|
+
// `selfTransition` action). A group's kind is set by its first edge; a multi-state self-loop
|
|
108
|
+
// (e.g. `save` on Draft AND Pending) accumulates each source into allowedStates.
|
|
109
|
+
const isSelf = el.source === el.target
|
|
110
|
+
if (!transitionGroups[actionKey]) {
|
|
111
|
+
transitionGroups[actionKey] = {
|
|
112
|
+
allowedStates: [sourceLabel],
|
|
113
|
+
label: edgeLabel,
|
|
114
|
+
selfLoop: isSelf,
|
|
115
|
+
...(isSelf ? {} : { nextState: targetLabel }),
|
|
96
116
|
}
|
|
117
|
+
} else {
|
|
118
|
+
transitionGroups[actionKey].allowedStates.push(sourceLabel)
|
|
97
119
|
}
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
const nextActions: Record<string, ActionDefinition> = {}
|
|
123
|
+
|
|
124
|
+
// Transitions (and self-transitions) derived from graph edges
|
|
125
|
+
for (const [actionKey, group] of Object.entries(transitionGroups)) {
|
|
126
|
+
const existing = existingWorkflow?.actions?.[actionKey]
|
|
127
|
+
nextActions[actionKey] = {
|
|
128
|
+
// Spread existing first so every field the graph does NOT own — clientHandler,
|
|
129
|
+
// requiredFields and any field added to ActionDefinition later — survives the
|
|
130
|
+
// round-trip. The graph owns topology (allowedStates/nextState/selfTransition) and the
|
|
131
|
+
// display label. (Enumerating named fields here previously dropped clientHandler.)
|
|
132
|
+
...existing,
|
|
133
|
+
// The edge's label is the display name (decoupled from the key). Fall back to the
|
|
134
|
+
// existing label, then the key, if an edge ever arrives label-less.
|
|
135
|
+
label: group.label || existing?.label || actionKey,
|
|
136
|
+
// The graph owns topology only. A self-transition (self-loop) stays in place: mark
|
|
137
|
+
// `selfTransition`, carry NO `nextState`. A cross-state transition carries `nextState`
|
|
138
|
+
// and clears any stale self flag (a self-loop redrawn as a normal edge). Explicit
|
|
139
|
+
// `undefined` on the unused field is dropped by JSON.stringify, so no format churn.
|
|
140
|
+
allowedStates: group.allowedStates,
|
|
141
|
+
nextState: group.selfLoop ? undefined : group.nextState,
|
|
142
|
+
selfTransition: group.selfLoop ? true : undefined,
|
|
107
143
|
}
|
|
108
144
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
|
|
146
|
+
// Pass through Verbs (stateless: true) and global Workflow actions (no allowedStates) verbatim
|
|
147
|
+
for (const [actionKey, actionDef] of Object.entries(existingWorkflow?.actions ?? {})) {
|
|
148
|
+
if (actionKey in transitionGroups) continue
|
|
149
|
+
if (actionDef.stateless || !actionDef.allowedStates?.length) {
|
|
150
|
+
nextActions[actionKey] = actionDef
|
|
113
151
|
}
|
|
152
|
+
// Workflow action with allowedStates not in graph = user deleted its edges → remove
|
|
114
153
|
}
|
|
115
154
|
|
|
116
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
// Spread existingWorkflow first so every top-level key the graph does NOT own — the
|
|
157
|
+
// sibling `triggers` map (field-validation triggers), and any WorkflowMeta key added
|
|
158
|
+
// later — survives the round-trip. The graph owns topology only: states + actions,
|
|
159
|
+
// overridden below. (Same principle the per-action spread applies above; enumerating
|
|
160
|
+
// only states+actions here previously dropped `triggers` on every graph edit.)
|
|
161
|
+
workflow: { ...existingWorkflow, states: stateNames, actions: nextActions },
|
|
162
|
+
layout: nextLayout,
|
|
163
|
+
}
|
|
117
164
|
}
|
package/dist/index.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import NodeEditor from './components/NodeEditor.vue';
|
|
2
|
-
import StateEditor from './components/StateEditor.vue';
|
|
3
|
-
/**
|
|
4
|
-
* Install all Node Editor components
|
|
5
|
-
* @param app - Vue app instance
|
|
6
|
-
* @public
|
|
7
|
-
*/
|
|
8
|
-
function install(app) {
|
|
9
|
-
app.component('NodeEditor', NodeEditor);
|
|
10
|
-
app.component('StateEditor', StateEditor);
|
|
11
|
-
}
|
|
12
|
-
export { install, NodeEditor, StateEditor };
|