@live-change/flow-frontend 0.9.226 → 0.9.228

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.
Files changed (56) hide show
  1. package/.node-version +1 -0
  2. package/.nvmrc +1 -0
  3. package/README.md +33 -0
  4. package/e2e/calculation.test.ts +247 -0
  5. package/e2e/e2eSuite.ts +8 -0
  6. package/e2e/env.ts +86 -0
  7. package/e2e/kitchen-sink.test.ts +85 -0
  8. package/e2e/runner.ts +10 -0
  9. package/e2e/withBrowser.ts +5 -0
  10. package/front/components.d.ts +25 -0
  11. package/front/locales/en.js +27 -0
  12. package/front/locales/en.json +5 -0
  13. package/front/src/App.vue +38 -12
  14. package/front/src/NavBar.vue +31 -0
  15. package/front/src/components/Edge.vue +1 -1
  16. package/front/src/components/EdgeEndHandle.vue +1 -1
  17. package/front/src/components/Flow.vue +5 -4
  18. package/front/src/components/Node.vue +1 -1
  19. package/front/src/components/NodeHandle.vue +1 -1
  20. package/front/src/components/NodePort.vue +1 -2
  21. package/front/src/components/index.js +10 -10
  22. package/front/src/config.js +90 -0
  23. package/front/src/demo/DemoEdge.vue +38 -0
  24. package/front/src/demo/DemoNodeCard.vue +86 -0
  25. package/front/src/demo/DemoPort.vue +34 -0
  26. package/front/src/demo/EnrichNode.vue +37 -0
  27. package/front/src/demo/FlowKitchenSink.vue +165 -0
  28. package/front/src/demo/OutputNode.vue +45 -0
  29. package/front/src/demo/SourceNode.vue +49 -0
  30. package/front/src/demo/calculation/AddNode.vue +35 -0
  31. package/front/src/demo/calculation/ConstantNode.vue +61 -0
  32. package/front/src/demo/calculation/FlowCalculationDemo.vue +300 -0
  33. package/front/src/demo/calculation/MultiplyNode.vue +35 -0
  34. package/front/src/demo/calculation/PowerNode.vue +62 -0
  35. package/front/src/demo/flowTestId.js +3 -0
  36. package/front/src/demo/runDemoGraph.js +76 -0
  37. package/front/src/demo/seedGraph.js +38 -0
  38. package/front/src/entry-client.js +2 -2
  39. package/front/src/entry-server.js +2 -1
  40. package/front/src/router.js +20 -12
  41. package/package.json +44 -37
  42. package/server/app.config.js +61 -0
  43. package/server/calculation/calculation.js +20 -0
  44. package/server/calculation/config.js +15 -0
  45. package/server/calculation/definition.js +11 -0
  46. package/server/calculation/index.js +12 -0
  47. package/server/calculation/ops.js +69 -0
  48. package/server/calculation/run.js +69 -0
  49. package/server/calculation/runGraph.js +85 -0
  50. package/server/calculation/tests/run.test.js +73 -0
  51. package/server/calculation/triggers.js +95 -0
  52. package/server/calculation/wire.js +30 -0
  53. package/server/init.js +1 -6
  54. package/server/services.list.js +11 -0
  55. package/server/start.js +39 -0
  56. package/server/services.config.js +0 -26
@@ -15,13 +15,14 @@
15
15
  transform, transformOrigin: '0 0',
16
16
  width: flow?.size?.width + 'px', height: flow?.size?.height + 'px'
17
17
  }">
18
- <!-- <pre class="absolute text-xs">{{ JSON.stringify(freeEdge, null, 2) }}</pre>-->
19
18
  <svg xmlns="http://www.w3.org/2000/svg" class="absolute w-full h-full pointer-events-none">
20
19
  <slot name="background-edges" v-bind="flow" />
21
20
  </svg>
22
- <slot v-bind="flow" />
23
21
  <svg xmlns="http://www.w3.org/2000/svg" class="absolute w-full h-full pointer-events-none">
24
22
  <slot name="foreground-edges" v-bind="flow" />
23
+ </svg>
24
+ <slot v-bind="flow" />
25
+ <svg xmlns="http://www.w3.org/2000/svg" class="absolute w-full h-full pointer-events-none">
25
26
  <slot name="free-edge" v-bind="{ freeEdge, flow }" />
26
27
  </svg>
27
28
  </div>
@@ -30,7 +31,7 @@
30
31
 
31
32
  <script setup>
32
33
 
33
- import { defineProps, defineEmits, toRefs, reactive, ref, onMounted, onUnmounted, watch, computed } from "vue"
34
+ import { defineProps, defineEmits, toRefs, reactive, ref, onMounted, onUnmounted, watch, computed, unref } from "vue"
34
35
  import { useFlow } from "./index.js"
35
36
 
36
37
  const viewport = ref(null)
@@ -127,7 +128,7 @@
127
128
  if(flow.options.connect) {
128
129
  flow.options.connect(newEdge)
129
130
  } else {
130
- flow.edges.push(newEdge)
131
+ unref(flow.edges).push(newEdge)
131
132
  }
132
133
  }
133
134
  },
@@ -17,7 +17,7 @@
17
17
 
18
18
  const { node } = toRefs(props)
19
19
 
20
- const flow = inject("flow")
20
+ const flow = inject("flow", null)
21
21
 
22
22
  const element = ref(null)
23
23
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  const { node } = toRefs(props)
21
21
 
22
- const flow = inject("flow")
22
+ const flow = inject("flow", null)
23
23
 
24
24
  function handleMouseDown(event) {
25
25
  event.preventDefault()
@@ -42,7 +42,7 @@
42
42
 
43
43
  const { node, portId, direction, newEdgeOptions } = toRefs(props)
44
44
 
45
- const flow = inject("flow")
45
+ const flow = inject("flow", null)
46
46
 
47
47
  const element = ref(null)
48
48
 
@@ -56,7 +56,6 @@
56
56
 
57
57
  function handleMouseDown(event) {
58
58
  event.preventDefault()
59
- console.log("start draw edge", node.value.id, portId.value)
60
59
  flow.startDrawEdge(node.value, portId.value, event, newEdgeOptions)
61
60
  }
62
61
 
@@ -1,7 +1,7 @@
1
1
  import { ref, unref, reactive, computed, inject, provide, watch } from "vue"
2
2
 
3
3
  export function useFlow(options) {
4
- const existingFlow = inject("flow")
4
+ const existingFlow = inject("flow", null)
5
5
  if(existingFlow && options) throw new Error("Flow already exists")
6
6
  if(existingFlow) return existingFlow
7
7
 
@@ -98,14 +98,15 @@ export function useFlow(options) {
98
98
  }
99
99
 
100
100
  function findNode(nodeId) {
101
+ nodeId = getNodeId(nodeId)
101
102
  if(options.findNode) return options.findNode(nodeId)
102
103
  if(options.findNodeIndex) return unref(nodes)[options.findNodeIndex(nodeId)]
103
- return unref(nodes).value.find(n => n.id == node)
104
+ return unref(nodes).find(n => n.id == nodeId)
104
105
  }
105
106
  function findNodeIndex(nodeId) {
106
107
  nodeId = getNodeId(nodeId)
107
108
  if(options.findNodeIndex) return options.findNodeIndex(nodeId)
108
- return unref(nodes).value.findIndex(n => n.id == node)
109
+ return unref(nodes).findIndex(n => n.id == nodeId)
109
110
  }
110
111
  function getNodeId(nodeId) {
111
112
  if(typeof nodeId == "string") return nodeId
@@ -118,14 +119,15 @@ export function useFlow(options) {
118
119
  }
119
120
 
120
121
  function findEdge(edgeId) {
122
+ edgeId = getEdgeId(edgeId)
121
123
  if(options.findEdge) return options.findEdge(edgeId)
122
124
  if(options.findEdgeIndex) return unref(edges)[options.findEdgeIndex(edgeId)]
123
- return unref(edges).value.find(e => e.id == edgeId)
125
+ return unref(edges).find(e => e.id == edgeId)
124
126
  }
125
127
  function findEdgeIndex(edgeId) {
126
128
  edgeId = getEdgeId(edgeId)
127
129
  if(options.findEdgeIndex) return options.findEdgeIndex(edgeId)
128
- return unref(edges).value.findIndex(e => e.id == edgeId)
130
+ return unref(edges).findIndex(e => e.id == edgeId)
129
131
  }
130
132
  function getEdgeId(edgeId) {
131
133
  if(typeof edgeId == "string") return edgeId
@@ -160,7 +162,6 @@ export function useFlow(options) {
160
162
  function setPortElement(nodeId, portId, element, direction) {
161
163
  nodeId = getNodeId(nodeId)
162
164
  if (typeof portId != "string") portId = portId.id ?? `${nodeId}/${portId.name}`
163
- console.log("setPortElement", nodeId, portId, element)
164
165
  const nodeView = nodeViews.get(nodeId)
165
166
  if(!nodeView) {
166
167
  return
@@ -265,9 +266,8 @@ export function useFlow(options) {
265
266
  }
266
267
 
267
268
  function startDrawEdge(nodeId, portId, event, newEdgeOptions) {
268
- console.log("startDrawEdge", nodeId, portId, event)
269
269
  nodeId = getNodeId(nodeId)
270
- if (typeof portId != "string") portId = portId.id ?? `${node.id}/${portId.name}`
270
+ if (typeof portId != "string") portId = portId.id ?? `${nodeId}/${portId.name}`
271
271
  dragState.value = {
272
272
  type: "drawEdge",
273
273
  node: nodeId,
@@ -283,7 +283,7 @@ export function useFlow(options) {
283
283
  if(options.deleteEdge) {
284
284
  options.deleteEdge(unref(edges)[edgeIndex])
285
285
  } else {
286
- edges.splice(edgeIndex, 1)
286
+ unref(edges).splice(edgeIndex, 1)
287
287
  }
288
288
  }
289
289
 
@@ -298,7 +298,7 @@ export function useFlow(options) {
298
298
  if(options.deleteNode) {
299
299
  options.deleteNode(unref(nodes)[nodeIndex])
300
300
  } else {
301
- nodes.splice(nodeIndex, 1)
301
+ unref(nodes).splice(nodeIndex, 1)
302
302
  }
303
303
  }
304
304
 
@@ -0,0 +1,90 @@
1
+ import deepmerge from 'deepmerge'
2
+
3
+ import * as en from '../locales/en.js'
4
+ import flowEn from '../locales/en.json'
5
+ import { locales as baseLocales } from '@live-change/frontend-base'
6
+
7
+ import Aura from '@primeuix/themes/aura'
8
+ import { definePreset } from '@primeuix/themes'
9
+
10
+ const levels = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
11
+
12
+ export default {
13
+
14
+ primeVue: {
15
+ theme: {
16
+ preset: definePreset(Aura, {
17
+ semantic: {
18
+ primary: Object.fromEntries(
19
+ levels.map(level => [level, `{blue.${level}}`])
20
+ ),
21
+ colorScheme: {
22
+ light: {
23
+ surface: {
24
+ 0: '#ffffff',
25
+ ...Object.fromEntries(
26
+ levels.map(level => [level, `{zinc.${level}}`])
27
+ )
28
+ },
29
+ primary: {
30
+ contrastColor: '#000000'
31
+ }
32
+ },
33
+ dark: {
34
+ surface: {
35
+ 0: '#ffffff',
36
+ ...Object.fromEntries(
37
+ levels.map(level => [level, `{slate.${level}}`])
38
+ )
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }),
44
+ options: {
45
+ prefix: 'p',
46
+ darkModeSelector: '.app-dark-mode',
47
+ cssLayer: {
48
+ name: 'primevue',
49
+ order: 'base, primevue'
50
+ }
51
+ }
52
+ },
53
+ ripple: true
54
+ },
55
+
56
+ defaultLocale: 'en',
57
+ localeSelector: ({ headers }) => {
58
+ const acceptLanguage = headers?.['accept-language']
59
+ if (acceptLanguage) {
60
+ const code = acceptLanguage.split(',')[0].trim().split('-')[0]
61
+ if (code) return code
62
+ }
63
+ if (typeof window !== 'undefined') {
64
+ const html = document.querySelector('html')
65
+ const lang = html?.getAttribute('lang')
66
+ if (lang) return lang.split('-')[0]
67
+ }
68
+ return 'en'
69
+ },
70
+
71
+ i18n: {
72
+ messages: {
73
+ en: deepmerge.all([
74
+ baseLocales.en,
75
+ flowEn,
76
+ en.messages
77
+ ])
78
+ },
79
+ numberFormats: {
80
+ en: deepmerge.all([
81
+ en.numberFormats
82
+ ])
83
+ },
84
+ datetimeFormats: {
85
+ en: deepmerge.all([
86
+ en.datetimeFormats
87
+ ])
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,38 @@
1
+ <template>
2
+ <Edge :edge="edge">
3
+ <template #default="{ src, dest }">
4
+ <EdgeBezierCurve :src="src" :dest="dest" v-slot="{ bezierPath }" >
5
+ <path :d="bezierPath" stroke="black" fill="none" stroke-width="10" stroke-linecap="round" />
6
+ <path :d="bezierPath" stroke="rgb(76, 175, 80)" fill="none" stroke-width="9" stroke-linecap="round"
7
+ :data-testid="`delete-edge-${edgeTestId}`" class="pointer-events-auto cursor-pointer"
8
+ @click.stop="flow.deleteEdge(edge)" />
9
+ </EdgeBezierCurve>
10
+
11
+ <EdgeEndHandle v-if="edge.src.node && edge.dest.node" :position="src" :edge="edge" end="src" />
12
+ <EdgeEndHandle v-if="edge.dest.node && edge.src.node" :position="dest" :edge="edge" end="dest" />
13
+ </template>
14
+ </Edge>
15
+ </template>
16
+
17
+ <script setup>
18
+
19
+ import Edge from "../components/Edge.vue"
20
+ import EdgeBezierCurve from "../components/EdgeBezierCurve.vue"
21
+ import EdgeEndHandle from "../components/EdgeEndHandle.vue"
22
+
23
+ import { defineProps, toRefs, computed } from "vue"
24
+ import { useFlow } from "../components/index.js"
25
+ import { flowTestId } from "./flowTestId.js"
26
+
27
+ const props = defineProps({
28
+ edge: {
29
+ type: Object,
30
+ required: true
31
+ }
32
+ })
33
+
34
+ const { edge } = toRefs(props)
35
+ const flow = useFlow()
36
+ const edgeTestId = computed(() => flowTestId(edge.value?.id))
37
+
38
+ </script>
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <Node :node="node">
3
+ <div class="demo-node-card">
4
+ <NodeHandle :node="node" class="demo-node-handle">
5
+ <i :class="['pi', icon, 'text-sm']" />
6
+ <span>{{ title }}</span>
7
+ </NodeHandle>
8
+ <div class="demo-node-body">
9
+ <slot />
10
+ </div>
11
+ <div class="demo-node-footer">
12
+ <Button @click="emit('delete')" size="small" severity="danger"
13
+ variant="outlined" rounded icon="pi pi-trash"
14
+ data-testid="delete-node" />
15
+ </div>
16
+ </div>
17
+ </Node>
18
+ </template>
19
+
20
+ <script setup>
21
+
22
+ import Node from "../components/Node.vue"
23
+ import NodeHandle from "../components/NodeHandle.vue"
24
+
25
+ import { defineProps, defineEmits, toRefs } from "vue"
26
+
27
+ const props = defineProps({
28
+ node: {
29
+ type: Object,
30
+ required: true
31
+ },
32
+ title: {
33
+ type: String,
34
+ required: true
35
+ },
36
+ icon: {
37
+ type: String,
38
+ required: true
39
+ }
40
+ })
41
+
42
+ const { node, title, icon } = toRefs(props)
43
+
44
+ const emit = defineEmits(['delete'])
45
+
46
+ </script>
47
+
48
+ <style>
49
+ .demo-node-card {
50
+ background: var(--p-surface-0, #ffffff);
51
+ border: 1px solid var(--p-surface-200, #e5e7eb);
52
+ border-radius: 12px;
53
+ box-shadow:
54
+ 0 4px 10px rgba(0, 0, 0, 0.12),
55
+ 0 1px 5px rgba(0, 0, 0, 0.12);
56
+ min-width: 13rem;
57
+ max-width: 16rem;
58
+ color: var(--p-surface-900, #212121);
59
+ }
60
+ .demo-node-handle {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.4rem;
64
+ font-size: 1.125rem;
65
+ font-weight: 500;
66
+ padding: 0.5rem 0.75rem 0.45rem;
67
+ margin-bottom: 0.15rem;
68
+ border-bottom: 1px solid var(--p-surface-200, #f3f4f6);
69
+ }
70
+ .demo-node-body {
71
+ padding-bottom: 0.15rem;
72
+ }
73
+ .demo-node-section {
74
+ font-size: 0.75rem;
75
+ font-weight: 700;
76
+ letter-spacing: 0.04em;
77
+ text-transform: uppercase;
78
+ color: var(--p-surface-500, #6b7280);
79
+ padding: 0.25rem 0.75rem 0.15rem;
80
+ }
81
+ .demo-node-footer {
82
+ display: flex;
83
+ justify-content: center;
84
+ padding: 0.25rem 0 0.5rem;
85
+ }
86
+ </style>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <div class="relative w-4 h-4 shrink-0">
3
+ <NodePort :node="node" :portId="portId" :direction="{ x, y: 0 }"
4
+ class="absolute rounded-full bg-green-500 w-4 h-4 border border-white"
5
+ :style="{ left: `${-0.5 + x * 0.3}rem`, top: `-0.5rem` }"
6
+ :data-testid="`port-${flowTestId(node.id)}-${portId}`">
7
+ </NodePort>
8
+ </div>
9
+ </template>
10
+
11
+ <script setup>
12
+
13
+ import { defineProps, toRefs } from "vue"
14
+ import NodePort from "../components/NodePort.vue"
15
+ import { flowTestId } from "./flowTestId.js"
16
+
17
+ const props = defineProps({
18
+ x: {
19
+ type: Number,
20
+ required: true
21
+ },
22
+ node: {
23
+ type: Object,
24
+ required: true
25
+ },
26
+ portId: {
27
+ type: String,
28
+ required: true
29
+ }
30
+ })
31
+
32
+ const { x, node, portId } = toRefs(props)
33
+
34
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <DemoNodeCard :node="node" title="Enrich" icon="pi-cog" @delete="deleteNode">
3
+ <div class="demo-node-section">Add field</div>
4
+ <div class="flex items-center p-1">
5
+ <DemoPort :x="-1" :node="node" portId="in" />
6
+ <div class="flex-1 mx-2">
7
+ <InputText v-model="node.field" class="w-full text-sm mb-2" placeholder="field name" />
8
+ <InputText v-model="node.value" class="w-full text-sm" placeholder="value" />
9
+ </div>
10
+ <DemoPort :x="1" :node="node" portId="out" />
11
+ </div>
12
+ </DemoNodeCard>
13
+ </template>
14
+
15
+ <script setup>
16
+
17
+ import DemoNodeCard from "./DemoNodeCard.vue"
18
+ import DemoPort from "./DemoPort.vue"
19
+ import { useFlow } from "../components/index.js"
20
+
21
+ import { defineProps, toRefs } from "vue"
22
+
23
+ const props = defineProps({
24
+ node: {
25
+ type: Object,
26
+ required: true
27
+ }
28
+ })
29
+
30
+ const { node } = toRefs(props)
31
+ const flow = useFlow()
32
+
33
+ function deleteNode() {
34
+ flow.deleteNode(node.value)
35
+ }
36
+
37
+ </script>
@@ -0,0 +1,165 @@
1
+ <template>
2
+ <div class="w-full h-screen flex flex-col">
3
+ <NavBar />
4
+ <div class="bg-surface-0 dark:bg-surface-900 py-2 px-4 shadow flex items-center gap-2 z-10 flex-wrap">
5
+ <Button label="Add source" icon="pi pi-plus" size="small" data-testid="add-source"
6
+ @click="addNode('source')" />
7
+ <Button label="Add enrich" icon="pi pi-plus" size="small" severity="secondary"
8
+ data-testid="add-enrich" @click="addNode('enrich')" />
9
+ <Button label="Add output" icon="pi pi-plus" size="small" severity="secondary"
10
+ data-testid="add-output" @click="addNode('output')" />
11
+ <Button label="Run sample" icon="pi pi-play" size="small" severity="success"
12
+ data-testid="run-sample" @click="runSample" />
13
+ <Button label="Reset" icon="pi pi-refresh" size="small" severity="warning" variant="outlined" @click="resetGraph" />
14
+ <span v-if="runError" class="text-red-600 text-sm ml-2">{{ runError }}</span>
15
+ </div>
16
+ <div class="flex flex-1 min-h-0">
17
+ <Flow class="flex-1 grow">
18
+ <template #default>
19
+ <component v-for="node of nodes" :key="node.id" :is="nodeComponentByType[node.type]" :node="node"
20
+ :style="{ left: node.position.x + 'px', top: node.position.y + 'px' }"
21
+ class="absolute" :data-testid="`node-${node.type}`">
22
+ </component>
23
+ </template>
24
+ <template #foreground-edges>
25
+ <DemoEdge v-for="edge of edges" :key="edge.id" :edge="edge" />
26
+ </template>
27
+ <template #free-edge="{ freeEdge }">
28
+ <DemoEdge v-if="freeEdge" :edge="freeEdge" />
29
+ </template>
30
+ </Flow>
31
+ <aside class="w-80 bg-surface-100 dark:bg-surface-800 border-l border-gray-200 overflow-auto p-3">
32
+ <div class="font-semibold mb-2">Last run</div>
33
+ <pre class="text-xs m-0 whitespace-pre-wrap" data-testid="last-run">{{ lastRunText }}</pre>
34
+ </aside>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup>
40
+
41
+ import Flow from '../components/Flow.vue'
42
+ import { useFlow } from '../components/index.js'
43
+ import NavBar from "../NavBar.vue"
44
+ import SourceNode from "./SourceNode.vue"
45
+ import EnrichNode from "./EnrichNode.vue"
46
+ import OutputNode from "./OutputNode.vue"
47
+ import DemoEdge from "./DemoEdge.vue"
48
+ import { createSeedGraph, STORAGE_KEY } from "./seedGraph.js"
49
+ import { runDemoGraph } from "./runDemoGraph.js"
50
+
51
+ import { computed, onMounted, ref, watch } from 'vue'
52
+
53
+ const nodeComponentByType = {
54
+ source: SourceNode,
55
+ enrich: EnrichNode,
56
+ output: OutputNode
57
+ }
58
+
59
+ const seed = createSeedGraph()
60
+ const nodes = ref(seed.nodes)
61
+ const edges = ref(seed.edges)
62
+ const lastRun = ref(null)
63
+ const runError = ref('')
64
+
65
+ const lastRunText = computed(() => {
66
+ if (!lastRun.value) return 'Press Run sample to execute the graph on the source record.'
67
+ return JSON.stringify(lastRun.value, null, 2)
68
+ })
69
+
70
+ function isConnectable(fromNode, fromPort, toNode, toPort) {
71
+ const fromId = typeof fromNode == 'string' ? fromNode : fromNode?.id
72
+ const toId = typeof toNode == 'string' ? toNode : toNode?.id
73
+ if (fromId && toId && fromId === toId) return false
74
+ if (!fromPort || !toPort) return true
75
+ return fromPort !== toPort
76
+ && (fromPort === 'in' || fromPort === 'out')
77
+ && (toPort === 'in' || toPort === 'out')
78
+ }
79
+
80
+ function connect(newEdge) {
81
+ if (newEdge.src.port !== 'out' && newEdge.dest.port === 'out') {
82
+ const tmp = newEdge.src
83
+ newEdge.src = newEdge.dest
84
+ newEdge.dest = tmp
85
+ }
86
+ edges.value.push(newEdge)
87
+ }
88
+
89
+ const flow = useFlow({
90
+ nodes,
91
+ edges,
92
+ width: 1600,
93
+ height: 900,
94
+ edgeConnectDistance: 48,
95
+ isConnectable,
96
+ connect
97
+ })
98
+
99
+ function persistableNodes() {
100
+ return nodes.value.map(node => {
101
+ const { lastRecord, ...rest } = node
102
+ return rest
103
+ })
104
+ }
105
+
106
+ onMounted(() => {
107
+ try {
108
+ const raw = localStorage.getItem(STORAGE_KEY)
109
+ if (raw) {
110
+ const parsed = JSON.parse(raw)
111
+ if (Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) {
112
+ nodes.value = parsed.nodes
113
+ edges.value = parsed.edges
114
+ }
115
+ }
116
+ } catch (err) {
117
+ // keep seed graph
118
+ }
119
+ watch([nodes, edges], () => {
120
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
121
+ nodes: persistableNodes(),
122
+ edges: edges.value
123
+ }))
124
+ }, { deep: true })
125
+ })
126
+
127
+ function addNode(type) {
128
+ const id = flow.generateNodeId()
129
+ const node = {
130
+ id,
131
+ type,
132
+ position: {
133
+ x: 80 - flow.position.x,
134
+ y: 80 - flow.position.y
135
+ }
136
+ }
137
+ if (type === 'source') node.record = { sku: 'NEW', qty: 1 }
138
+ if (type === 'enrich') {
139
+ node.field = 'extra'
140
+ node.value = ''
141
+ }
142
+ nodes.value.push(node)
143
+ }
144
+
145
+ function resetGraph() {
146
+ const next = createSeedGraph()
147
+ nodes.value = next.nodes
148
+ edges.value = next.edges
149
+ lastRun.value = null
150
+ runError.value = ''
151
+ }
152
+
153
+ function runSample() {
154
+ const result = runDemoGraph(nodes.value, edges.value)
155
+ lastRun.value = result
156
+ runError.value = result.error || ''
157
+ if (result.traces) {
158
+ for (const trace of result.traces) {
159
+ const node = nodes.value.find(n => n.id === trace.nodeId)
160
+ if (node) node.lastRecord = trace.record
161
+ }
162
+ }
163
+ }
164
+
165
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <DemoNodeCard :node="node" title="Output" icon="pi-box" @delete="deleteNode">
3
+ <div class="demo-node-section">Result</div>
4
+ <div class="flex items-center p-1">
5
+ <DemoPort :x="-1" :node="node" portId="in" />
6
+ <pre class="demo-node-result flex-1 mx-2 my-0 overflow-auto">{{
7
+ JSON.stringify(node.lastRecord ?? {}, null, 2)
8
+ }}</pre>
9
+ </div>
10
+ </DemoNodeCard>
11
+ </template>
12
+
13
+ <script setup>
14
+
15
+ import DemoNodeCard from "./DemoNodeCard.vue"
16
+ import DemoPort from "./DemoPort.vue"
17
+ import { useFlow } from "../components/index.js"
18
+
19
+ import { defineProps, toRefs } from "vue"
20
+
21
+ const props = defineProps({
22
+ node: {
23
+ type: Object,
24
+ required: true
25
+ }
26
+ })
27
+
28
+ const { node } = toRefs(props)
29
+ const flow = useFlow()
30
+
31
+ function deleteNode() {
32
+ flow.deleteNode(node.value)
33
+ }
34
+
35
+ </script>
36
+
37
+ <style scoped>
38
+ .demo-node-result {
39
+ font-size: 0.75rem;
40
+ max-height: 8rem;
41
+ background: var(--p-surface-50, #f9fafb);
42
+ border-radius: 8px;
43
+ padding: 0.4rem 0.5rem;
44
+ }
45
+ </style>