@live-change/flow-frontend 0.9.226 → 0.9.227

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 (57) 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/LICENSE.md +0 -11
  57. package/server/services.config.js +0 -26
package/.node-version ADDED
@@ -0,0 +1 @@
1
+ 20.20.2
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20.20.2
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @live-change/flow-frontend
2
+
3
+ Headless Vue 3 **node-and-edge canvas**: pan, zoom, drag nodes, connect ports, reconnect edge ends.
4
+
5
+ The app supplies node/edge Vue wrappers, graph data, persistence, and any execution runtime.
6
+
7
+ ## Public exports
8
+
9
+ `useFlow`, `Flow`, `Node`, `NodeHandle`, `NodePort`, `Edge`, `EdgeBezierCurve`, `EdgeEndHandle`
10
+
11
+ ## Run the kitchen-sink demo
12
+
13
+ From this package directory (use the repo Node version via `fnm`):
14
+
15
+ ```bash
16
+ fnm exec -- yarn memDev
17
+ ```
18
+
19
+ Open the printed local URL. `/` is a sample dataflow (source → enrich → output) with `localStorage` persistence and **Run sample**.
20
+
21
+ ## Docs
22
+
23
+ - Stack chapter: [Frontend – Flow editor](../../docs/docs/frontend/14-flow-frontend.md)
24
+ - Copy-paste demo: `front/src/demo/`
25
+
26
+ ## Embedding in another app
27
+
28
+ ```javascript
29
+ import { Flow, useFlow, Node, NodeHandle, NodePort, Edge, EdgeBezierCurve, EdgeEndHandle }
30
+ from '@live-change/flow-frontend'
31
+ ```
32
+
33
+ Do not import `front/src/demo/*` from the package entry — those files are the sample app only.
@@ -0,0 +1,247 @@
1
+ import assert from 'node:assert'
2
+ import { withBrowser } from './withBrowser.js'
3
+ import { e2eSuite, test } from './e2eSuite.js'
4
+ import type { TestEnv } from './env.js'
5
+ import { flowTestId } from '../front/src/demo/flowTestId.js'
6
+
7
+ async function waitUntil(fn: () => Promise<boolean>, timeoutMs = 10000, message = 'waitUntil timeout') {
8
+ const start = Date.now()
9
+ while (Date.now() - start < timeoutMs) {
10
+ if (await fn()) return
11
+ await new Promise(resolve => setTimeout(resolve, 100))
12
+ }
13
+ throw new Error(message)
14
+ }
15
+
16
+ function graphIdFor(calculationId: string) {
17
+ return JSON.stringify('calculation_Calculation') + ':' + JSON.stringify(calculationId)
18
+ }
19
+
20
+ async function waitForCalculation(page: import('playwright').Page) {
21
+ await page.getByTestId('add-constant').waitFor({ state: 'visible', timeout: 60000 })
22
+ await page.waitForURL(/\/calc\/.+/, { timeout: 60000 })
23
+ const calculationId = page.url().split('/').pop() as string
24
+ assert.ok(calculationId && calculationId !== 'calc', 'calculation id in url')
25
+ await waitUntil(async () => !(await page.getByTestId('add-constant').isDisabled()), 20000)
26
+ return calculationId
27
+ }
28
+
29
+ async function fillNumber(locator: import('playwright').Locator, value: string) {
30
+ const input = locator.locator('input').first()
31
+ await input.waitFor({ state: 'visible', timeout: 15000 })
32
+ await input.click({ clickCount: 3 })
33
+ await input.fill(value)
34
+ await input.blur()
35
+ }
36
+
37
+ async function dragPort(
38
+ page: import('playwright').Page,
39
+ fromTestId: string,
40
+ toTestId: string
41
+ ) {
42
+ const from = page.getByTestId(fromTestId)
43
+ const to = page.getByTestId(toTestId)
44
+ await from.waitFor({ state: 'visible', timeout: 15000 })
45
+ await to.waitFor({ state: 'visible', timeout: 15000 })
46
+ await from.scrollIntoViewIfNeeded()
47
+ await to.scrollIntoViewIfNeeded()
48
+ const fromBox = await from.boundingBox()
49
+ const toBox = await to.boundingBox()
50
+ assert.ok(fromBox && toBox, `missing bounding box for ${fromTestId} or ${toTestId}`)
51
+ await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2)
52
+ await page.mouse.down()
53
+ await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 })
54
+ await page.mouse.up()
55
+ }
56
+
57
+ e2eSuite('calculation', () => {
58
+ test('page creates a Calculation and Graph', async () => {
59
+ await withBrowser(async (page, env: TestEnv) => {
60
+ await page.goto(env.url + '/calc', { waitUntil: 'domcontentloaded' })
61
+ const calculationId = await waitForCalculation(page)
62
+ const graph = await env.grabObject('flow', 'Graph', graphIdFor(calculationId))
63
+ assert.ok(graph, 'Graph row exists for the calculation')
64
+ env.haveAction('calculation', 'runCalculation')
65
+ env.haveView('flow', 'graphOwnedEdges')
66
+ })
67
+ })
68
+
69
+ test('add Constant + Add, connect, run shows the sum', async () => {
70
+ await withBrowser(async (page, env: TestEnv) => {
71
+ await page.goto(env.url + '/calc', { waitUntil: 'domcontentloaded' })
72
+ const calculationId = await waitForCalculation(page)
73
+ const gid = graphIdFor(calculationId)
74
+
75
+ await page.getByTestId('add-constant').click()
76
+ await waitUntil(async () => {
77
+ const nodes = await env.haveModel('flow', 'Node').sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
78
+ return (nodes || []).length >= 1
79
+ }, 15000, 'expected 1 node after first add-constant')
80
+ await page.getByTestId('add-constant').click()
81
+ await waitUntil(async () => {
82
+ const nodes = await env.haveModel('flow', 'Node').sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
83
+ return (nodes || []).length >= 2
84
+ }, 15000, 'expected 2 nodes after second add-constant')
85
+ await page.getByTestId('add-add').click()
86
+ await waitUntil(async () => {
87
+ const nodes = await env.haveModel('flow', 'Node').sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
88
+ return (nodes || []).length >= 3
89
+ }, 15000, 'expected 3 nodes after add-add')
90
+
91
+ const constantNodes = page.getByTestId('node-calculation_Constant')
92
+ await constantNodes.first().waitFor({ state: 'visible', timeout: 15000 })
93
+ await fillNumber(constantNodes.nth(0).getByTestId('constant-value'), '3')
94
+ await fillNumber(constantNodes.nth(1).getByTestId('constant-value'), '4')
95
+ await waitUntil(async () => {
96
+ const rows = await env.haveModel('calculation', 'Constant')
97
+ .sortedIndexRangeGet('byCalculation', [calculationId])
98
+ const values = []
99
+ for (const row of rows || []) {
100
+ const id = (row as { to?: string, id?: string }).to
101
+ || (row as { id?: string }).id as string
102
+ const obj = await env.grabObject('calculation', 'Constant', id) as { value?: number }
103
+ values.push(obj?.value)
104
+ }
105
+ return values.includes(3) && values.includes(4)
106
+ }, 15000, 'constant values 3 and 4 were not saved')
107
+
108
+ const constants = await env.haveModel('calculation', 'Constant')
109
+ .sortedIndexRangeGet('byCalculation', [calculationId])
110
+ const addRows = await env.haveModel('calculation', 'Add')
111
+ .sortedIndexRangeGet('byCalculation', [calculationId])
112
+ assert.ok((constants || []).length >= 2)
113
+ assert.ok((addRows || []).length >= 1)
114
+
115
+ const flowNodes = await env.haveModel('flow', 'Node')
116
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
117
+ const nodeObjects = []
118
+ for (const row of flowNodes || []) {
119
+ const id = (row as { to?: string, id?: string }).to
120
+ || (row as { id?: string }).id as string
121
+ nodeObjects.push(await env.grabObject('flow', 'Node', id) as {
122
+ id: string, logicType: string, logic: string
123
+ })
124
+ }
125
+ const constantFlowNodes = nodeObjects.filter(n => n.logicType == 'calculation_Constant')
126
+ const addFlowNode = nodeObjects.find(n => n.logicType == 'calculation_Add')
127
+ assert.ok(addFlowNode)
128
+
129
+ for (const constantNode of constantFlowNodes.slice(0, 2)) {
130
+ await dragPort(
131
+ page,
132
+ `port-${flowTestId(constantNode.id)}-out`,
133
+ `port-${flowTestId(addFlowNode!.id)}-in`
134
+ )
135
+ await page.waitForTimeout(400)
136
+ }
137
+
138
+ await waitUntil(async () => {
139
+ const edges = await env.haveModel('flow', 'Edge')
140
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
141
+ return (edges || []).length >= 2
142
+ }, 15000, 'expected 2 edges after connecting constants to add')
143
+
144
+ await page.getByTestId('run-calculation').click()
145
+ const results = page.getByTestId('run-results')
146
+ await waitUntil(async () => {
147
+ const text = await results.innerText()
148
+ return text.includes('7')
149
+ }, 15000, 'run results should include 7')
150
+
151
+ await page.getByTestId('add-power').click()
152
+ await waitUntil(async () => {
153
+ const powers = await env.haveModel('calculation', 'Power')
154
+ .sortedIndexRangeGet('byCalculation', [calculationId])
155
+ return (powers || []).length >= 1
156
+ })
157
+ const powerNodeRow = (await env.haveModel('flow', 'Node')
158
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid]) || [])
159
+ const powerNodes = []
160
+ for (const row of powerNodeRow as { to?: string, id?: string }[]) {
161
+ const id = row.to || row.id as string
162
+ const obj = await env.grabObject('flow', 'Node', id) as {
163
+ id: string, logicType: string
164
+ }
165
+ if (obj.logicType == 'calculation_Power') powerNodes.push(obj)
166
+ }
167
+ assert.ok(powerNodes[0], 'Power node exists')
168
+ const edgesBeforePower = ((await env.haveModel('flow', 'Edge')
169
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])) || []).length
170
+ await dragPort(
171
+ page,
172
+ `port-${flowTestId(addFlowNode!.id)}-out`,
173
+ `port-${flowTestId(powerNodes[0].id)}-in`
174
+ )
175
+ await waitUntil(async () => {
176
+ const edges = await env.haveModel('flow', 'Edge')
177
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
178
+ return (edges || []).length > edgesBeforePower
179
+ }, 15000, 'expected an edge from add to power')
180
+ await page.getByTestId('run-calculation').click()
181
+ await waitUntil(async () => {
182
+ const text = await results.innerText()
183
+ return text.includes('49')
184
+ }, 15000, 'run results should include 49')
185
+ })
186
+ })
187
+
188
+ test('delete edge removes Wire; delete node removes logic and Node', async () => {
189
+ await withBrowser(async (page, env: TestEnv) => {
190
+ await page.goto(env.url + '/calc', { waitUntil: 'domcontentloaded' })
191
+ const calculationId = await waitForCalculation(page)
192
+ const gid = graphIdFor(calculationId)
193
+
194
+ await page.getByTestId('add-constant').click()
195
+ await waitUntil(async () => {
196
+ const nodes = await env.haveModel('flow', 'Node').sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
197
+ return (nodes || []).length >= 1
198
+ }, 15000, 'expected 1 node after add-constant')
199
+ await page.getByTestId('add-add').click()
200
+ await waitUntil(async () => {
201
+ const nodes = await env.haveModel('flow', 'Node').sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
202
+ return (nodes || []).length >= 2
203
+ }, 15000, 'expected 2 nodes after add-add')
204
+
205
+ const flowNodes = await env.haveModel('flow', 'Node')
206
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
207
+ const nodeObjects = []
208
+ for (const row of flowNodes || []) {
209
+ const id = (row as { to?: string, id?: string }).to
210
+ || (row as { id?: string }).id as string
211
+ nodeObjects.push(await env.grabObject('flow', 'Node', id) as {
212
+ id: string, logicType: string, logic: string
213
+ })
214
+ }
215
+ const constantNode = nodeObjects.find(n => n.logicType == 'calculation_Constant')
216
+ const addNode = nodeObjects.find(n => n.logicType == 'calculation_Add')
217
+ assert.ok(constantNode && addNode)
218
+
219
+ await dragPort(
220
+ page,
221
+ `port-${flowTestId(constantNode.id)}-out`,
222
+ `port-${flowTestId(addNode.id)}-in`
223
+ )
224
+ await waitUntil(async () => {
225
+ const edges = await env.haveModel('flow', 'Edge')
226
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
227
+ return (edges || []).length >= 1
228
+ }, 15000, 'expected an edge after connecting constant to add')
229
+
230
+ const edgesBefore = await env.haveModel('flow', 'Edge')
231
+ .sortedIndexRangeGet('byGraph', ['flow_Graph', gid])
232
+ const edgeRow = edgesBefore[0] as { to?: string, id?: string, connection?: string }
233
+ const edgeId = edgeRow.to || edgeRow.id as string
234
+ const edge = await env.grabObject('flow', 'Edge', edgeId) as { connection: string }
235
+ assert.ok(edge?.connection)
236
+
237
+ await page.getByTestId(`delete-edge-${flowTestId(edgeId)}`).click({ force: true })
238
+ await waitUntil(async () => !(await env.grabObject('calculation', 'Wire', edge.connection)))
239
+ await waitUntil(async () => !(await env.grabObject('flow', 'Edge', edgeId)))
240
+
241
+ const logicId = constantNode.logic
242
+ await page.getByTestId('node-calculation_Constant').first().getByTestId('delete-node').click()
243
+ await waitUntil(async () => !(await env.grabObject('calculation', 'Constant', logicId)))
244
+ await waitUntil(async () => !(await env.grabObject('flow', 'Node', constantNode.id)))
245
+ })
246
+ })
247
+ })
@@ -0,0 +1,8 @@
1
+ export {
2
+ e2eSuite,
3
+ getE2ERegistry,
4
+ resetE2ERegistry,
5
+ setCurrentE2EFile,
6
+ test,
7
+ type E2ETestDefinition
8
+ } from '@live-change/e2e-test'
package/e2e/env.ts ADDED
@@ -0,0 +1,86 @@
1
+ import path from 'path'
2
+ import { fileURLToPath } from 'url'
3
+ import { TestServer } from '@live-change/server'
4
+ import { createTestEnvHelpers, waitForServerReady } from '@live-change/e2e-test'
5
+ import appConfig from '../server/app.config.js'
6
+ import * as services from '../server/services.list.js'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const serverDir = path.join(__dirname, '..', 'server')
10
+ const frontDir = path.join(__dirname, '..', 'front')
11
+
12
+ for (const serviceConfig of appConfig.services) {
13
+ const name = (serviceConfig as { name: string }).name
14
+ ;(serviceConfig as { module?: unknown }).module = (services as Record<string, unknown>)[name]
15
+ }
16
+ ;(appConfig as { init?: (s: unknown) => Promise<void> }).init =
17
+ (services as { init: (s: unknown) => Promise<void> }).init
18
+
19
+ export type TestEnv = {
20
+ server: InstanceType<typeof TestServer>
21
+ url: string
22
+ haveService: (name: string) => { name: string; models: Record<string, { get: (id: string) => Promise<unknown>; sortedIndexRangeGet?: (index: string, key: unknown) => Promise<unknown[]> }>; views: Record<string, unknown>; actions: Record<string, unknown>; triggers: Record<string, unknown> }
23
+ haveModel: (serviceName: string, modelName: string) => { get: (id: string) => Promise<unknown>; sortedIndexRangeGet: (index: string, key: unknown) => Promise<unknown[]> }
24
+ haveView: (serviceName: string, viewName: string) => unknown
25
+ haveAction: (serviceName: string, actionName: string) => unknown
26
+ haveTrigger: (serviceName: string, triggerName: string) => unknown
27
+ grabObject: (serviceName: string, modelName: string, id: string) => Promise<unknown>
28
+ }
29
+
30
+ type TestServerInstance = InstanceType<typeof TestServer>
31
+
32
+ let envPromise: Promise<TestEnv> | null = null
33
+ let testServer: TestServerInstance | null = null
34
+
35
+ export async function disposeTestEnv(): Promise<void> {
36
+ const s = testServer
37
+ if (!s) return
38
+ testServer = null
39
+ envPromise = null
40
+ await s.dispose()
41
+ }
42
+
43
+ export async function getTestEnv(): Promise<TestEnv> {
44
+ if (envPromise) return envPromise
45
+ envPromise = (async () => {
46
+ const server = new TestServer({
47
+ dev: true,
48
+ enableSessions: true,
49
+ port: 0,
50
+ ssrRoot: frontDir,
51
+ initScript: path.join(serverDir, 'init.js'),
52
+ services: appConfig
53
+ })
54
+ console.log('starting test server')
55
+ try {
56
+ await server.start()
57
+ } catch (error) {
58
+ console.error((error as Error).stack)
59
+ process.exit(1)
60
+ }
61
+ console.log('Test server started at', server.url)
62
+ console.log('waiting for front to be ready...')
63
+ await waitForServerReady(server.url!)
64
+ console.log('front ready')
65
+
66
+ testServer = server
67
+
68
+ process.on('beforeExit', () => {
69
+ void disposeTestEnv()
70
+ })
71
+
72
+ const url = server.url!
73
+ const helpers = createTestEnvHelpers(server)
74
+ return {
75
+ server,
76
+ url,
77
+ haveService: helpers.haveService,
78
+ haveModel: helpers.haveModel,
79
+ haveView: helpers.haveView,
80
+ haveAction: helpers.haveAction,
81
+ haveTrigger: helpers.haveTrigger,
82
+ grabObject: helpers.grabObject
83
+ }
84
+ })()
85
+ return envPromise
86
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert'
2
+ import { withBrowser } from './withBrowser.js'
3
+ import { e2eSuite, test } from './e2eSuite.js'
4
+
5
+ async function dragPort(
6
+ page: import('playwright').Page,
7
+ from: import('playwright').Locator,
8
+ to: import('playwright').Locator
9
+ ) {
10
+ await from.waitFor({ state: 'visible', timeout: 15000 })
11
+ await to.waitFor({ state: 'visible', timeout: 15000 })
12
+ await from.scrollIntoViewIfNeeded()
13
+ await to.scrollIntoViewIfNeeded()
14
+ const fromBox = await from.boundingBox()
15
+ const toBox = await to.boundingBox()
16
+ assert.ok(fromBox && toBox, 'missing port bounding box')
17
+ await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2)
18
+ await page.mouse.down()
19
+ await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 })
20
+ await page.mouse.up()
21
+ }
22
+
23
+ e2eSuite('kitchen-sink', () => {
24
+ test('page renders kitchen sink toolbar', async () => {
25
+ await withBrowser(async (page, env) => {
26
+ const response = await page.goto(env.url + '/', { waitUntil: 'domcontentloaded' })
27
+ assert.ok(response, 'navigation returned response')
28
+ assert.ok(response!.ok(), 'homepage responds with success status')
29
+ await page.getByTestId('nav-kitchen-sink').waitFor({ state: 'visible', timeout: 30000 })
30
+ await page.getByTestId('add-source').waitFor({ state: 'visible' })
31
+ await page.getByTestId('run-sample').waitFor({ state: 'visible' })
32
+ await page.getByTestId('node-source').first().waitFor({ state: 'visible' })
33
+ })
34
+ })
35
+
36
+ test('add a node, run sample, delete a node', async () => {
37
+ await withBrowser(async (page, env) => {
38
+ await page.goto(env.url + '/', { waitUntil: 'domcontentloaded' })
39
+ await page.getByTestId('add-source').waitFor({ state: 'visible', timeout: 30000 })
40
+
41
+ const before = await page.getByTestId('node-source').count()
42
+ await page.getByTestId('add-source').click()
43
+ await page.waitForTimeout(300)
44
+ const afterAdd = await page.getByTestId('node-source').count()
45
+ assert.ok(afterAdd > before, 'adding a source should create a node')
46
+
47
+ await page.getByTestId('run-sample').click()
48
+ const lastRun = page.getByTestId('last-run')
49
+ await lastRun.waitFor({ state: 'visible' })
50
+ const text = await lastRun.innerText()
51
+ assert.ok(text.includes('sku') || text.includes('traces') || text.includes('{'),
52
+ 'run sample should show JSON')
53
+
54
+ await page.getByTestId('node-source').last().getByTestId('delete-node').click()
55
+ await page.waitForTimeout(300)
56
+ const afterDelete = await page.getByTestId('node-source').count()
57
+ assert.equal(afterDelete, afterAdd - 1)
58
+
59
+ await page.getByTestId('run-sample').click()
60
+ const afterDeleteRun = await lastRun.innerText()
61
+ assert.ok(afterDeleteRun.length > 0, 'graph remains runnable after delete')
62
+ })
63
+ })
64
+
65
+ test('drag out port to in port creates an edge', async () => {
66
+ await withBrowser(async (page, env) => {
67
+ await page.goto(env.url + '/', { waitUntil: 'domcontentloaded' })
68
+ await page.getByTestId('add-enrich').waitFor({ state: 'visible', timeout: 30000 })
69
+ await page.getByTestId('add-enrich').click()
70
+ await page.waitForTimeout(300)
71
+
72
+ const outPort = page.getByTestId('port-source-out')
73
+ const inPorts = page.getByTestId(/port-.*-in/)
74
+ await outPort.waitFor({ state: 'visible' })
75
+ const target = inPorts.last()
76
+ await target.waitFor({ state: 'visible' })
77
+ await dragPort(page, outPort, target)
78
+ await page.waitForTimeout(400)
79
+
80
+ await page.getByTestId('run-sample').click()
81
+ const text = await page.getByTestId('last-run').innerText()
82
+ assert.ok(text.includes('{'), 'graph still runs after connecting')
83
+ })
84
+ })
85
+ })
package/e2e/runner.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createRunner } from '@live-change/e2e-test'
2
+ import { disposeTestEnv, getTestEnv } from './env.js'
3
+
4
+ const runner = createRunner({
5
+ setupEnv: getTestEnv,
6
+ teardownEnv: disposeTestEnv
7
+ })
8
+
9
+ export const runE2E = runner.runE2E
10
+ await runner.runCli(import.meta.url, process.argv.slice(2))
@@ -0,0 +1,5 @@
1
+ import { createWithBrowser } from '@live-change/e2e-test'
2
+ import { getTestEnv } from './env.js'
3
+ import type { TestEnv } from './env.js'
4
+
5
+ export const withBrowser = createWithBrowser<TestEnv>(getTestEnv)
@@ -0,0 +1,25 @@
1
+ /* eslint-disable */
2
+ // @ts-nocheck
3
+ // Generated by unplugin-vue-components
4
+ // Read more: https://github.com/vuejs/core/pull/3399
5
+ // biome-ignore lint: disable
6
+ export {}
7
+
8
+ /* prettier-ignore */
9
+ declare module 'vue' {
10
+ export interface GlobalComponents {
11
+ Button: typeof import('primevue/button')['default']
12
+ Dialog: typeof import('primevue/dialog')['default']
13
+ Edge: typeof import('./src/components/Edge.vue')['default']
14
+ EdgeBezierCurve: typeof import('./src/components/EdgeBezierCurve.vue')['default']
15
+ EdgeEndHandle: typeof import('./src/components/EdgeEndHandle.vue')['default']
16
+ Flow: typeof import('./src/components/Flow.vue')['default']
17
+ InputText: typeof import('primevue/inputtext')['default']
18
+ Node: typeof import('./src/components/Node.vue')['default']
19
+ NodeHandle: typeof import('./src/components/NodeHandle.vue')['default']
20
+ NodePort: typeof import('./src/components/NodePort.vue')['default']
21
+ RouterLink: typeof import('vue-router')['RouterLink']
22
+ RouterView: typeof import('vue-router')['RouterView']
23
+ Textarea: typeof import('primevue/textarea')['default']
24
+ }
25
+ }
@@ -0,0 +1,27 @@
1
+ import messages from './en.json'
2
+
3
+ export { messages }
4
+
5
+ export const numberFormats = {
6
+ usd: {
7
+ style: 'currency',
8
+ currency: 'USD',
9
+ notation: 'standard'
10
+ }
11
+ }
12
+
13
+ export const datetimeFormats = {
14
+ short: {
15
+ year: 'numeric', month: 'short', day: 'numeric'
16
+ },
17
+ shortTime: {
18
+ dateStyle: 'short', timeStyle: 'short', hour12: false
19
+ },
20
+ shortestTime: {
21
+ month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: false
22
+ },
23
+ long: {
24
+ year: 'numeric', month: 'short', day: 'numeric',
25
+ weekday: 'short', hour: 'numeric', minute: 'numeric'
26
+ }
27
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "flow": {
3
+ "title": "Flow editor"
4
+ }
5
+ }
package/front/src/App.vue CHANGED
@@ -7,15 +7,18 @@
7
7
  </template>
8
8
 
9
9
  <script setup>
10
- import 'primevue/resources/themes/saga-green/theme.css'
11
- import "@fortawesome/fontawesome-free/css/all.min.css"
12
10
 
13
- import { ViewRoot, NavBar } from "@live-change/frontend-base"
11
+ import { useLocale } from '@live-change/vue3-components'
12
+ const locale = useLocale()
13
+ locale.captureLocale()
14
+
15
+ import NavBar from "./NavBar.vue"
16
+ import ViewRoot from "@live-change/frontend-base/ViewRoot.vue"
14
17
 
15
18
  import { computed } from 'vue'
16
19
  import { useHead } from '@vueuse/head'
17
20
  useHead(computed(() => ({
18
- title: 'Title',
21
+ title: 'Flow editor',
19
22
  meta: [
20
23
  { charset: 'utf-8' },
21
24
  { name: 'viewport',
@@ -28,13 +31,36 @@
28
31
  }
29
32
  })))
30
33
 
31
- import { watch } from 'vue'
32
- import { client as useClient, useApi } from '@live-change/vue3-ssr'
33
- const client = useClient()
34
- watch(client, (newClient, oldClient) => {
35
- console.log("WATCH CLIENT", oldClient, '=>', newClient)
36
- })
34
+ </script>
37
35
 
38
- const api = useApi()
36
+ <style>
37
+ @import "tailwindcss";
38
+ @plugin "tailwindcss-primeui";
39
39
 
40
- </script>
40
+ @custom-variant dark (&:where(.app-dark-mode, .app-dark-mode *));
41
+
42
+ :root { font-family: 'Verdana', sans-serif; }
43
+ @supports (font-variation-settings: normal) {
44
+ :root { font-family: 'Verdana var', sans-serif; }
45
+ }
46
+ html,body
47
+ {
48
+ min-height:100%;
49
+ font-family: 'Verdana var', 'Verdana', sans-serif;
50
+ }
51
+
52
+ @layer base {
53
+ a {
54
+ color: var(--p-blue-700);
55
+ text-decoration: underline;
56
+ }
57
+ a:hover {
58
+ color: var(--p-blue-900);
59
+ text-decoration: none;
60
+ }
61
+ a:visited {
62
+ color: var(--p-purple-800);
63
+ }
64
+ }
65
+
66
+ </style>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div class="bg-surface-0 dark:bg-surface-900 py-1 px-6 shadow flex items-center justify-between
3
+ relative md:sticky top-0 z-5"
4
+ style="min-height: 64px" key="navbar">
5
+
6
+ <router-link :to="{ name: 'index' }" class="no-underline text-gray-700 text-2xl font-medium">
7
+ <div class="flex items-center">
8
+ <img src="/images/logo.svg" class="w-10 mr-4">
9
+ <span class="text-surface-700 dark:text-surface-400">
10
+ Flow editor
11
+ </span>
12
+ </div>
13
+ </router-link>
14
+
15
+ <nav class="flex items-center gap-4">
16
+ <router-link :to="{ name: 'index' }" data-testid="nav-kitchen-sink"
17
+ class="no-underline text-surface-700 dark:text-surface-400">
18
+ Kitchen sink
19
+ </router-link>
20
+ <router-link :to="{ name: 'calculation' }" data-testid="nav-calculation"
21
+ class="no-underline text-surface-700 dark:text-surface-400">
22
+ Calculation
23
+ </router-link>
24
+ </nav>
25
+
26
+ </div>
27
+ </template>
28
+
29
+ <script setup>
30
+
31
+ </script>
@@ -17,7 +17,7 @@
17
17
 
18
18
  const { edge } = toRefs(props)
19
19
 
20
- const flow = inject("flow")
20
+ const flow = inject("flow", null)
21
21
 
22
22
  const srcView = computed(() => edge.value.src.node && flow.getPortView(edge.value.src.node, edge.value.src.port))
23
23
  const destView = computed(() => edge.value.dest.node && flow.getPortView(edge.value.dest.node, edge.value.dest.port))
@@ -111,7 +111,7 @@
111
111
  ].join(' ')
112
112
  })
113
113
 
114
- const flow = inject("flow")
114
+ const flow = inject("flow", null)
115
115
 
116
116
  function handleMouseDown(event) {
117
117
  event.preventDefault()