@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.
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/README.md +33 -0
- package/e2e/calculation.test.ts +247 -0
- package/e2e/e2eSuite.ts +8 -0
- package/e2e/env.ts +86 -0
- package/e2e/kitchen-sink.test.ts +85 -0
- package/e2e/runner.ts +10 -0
- package/e2e/withBrowser.ts +5 -0
- package/front/components.d.ts +25 -0
- package/front/locales/en.js +27 -0
- package/front/locales/en.json +5 -0
- package/front/src/App.vue +38 -12
- package/front/src/NavBar.vue +31 -0
- package/front/src/components/Edge.vue +1 -1
- package/front/src/components/EdgeEndHandle.vue +1 -1
- package/front/src/components/Flow.vue +5 -4
- package/front/src/components/Node.vue +1 -1
- package/front/src/components/NodeHandle.vue +1 -1
- package/front/src/components/NodePort.vue +1 -2
- package/front/src/components/index.js +10 -10
- package/front/src/config.js +90 -0
- package/front/src/demo/DemoEdge.vue +38 -0
- package/front/src/demo/DemoNodeCard.vue +86 -0
- package/front/src/demo/DemoPort.vue +34 -0
- package/front/src/demo/EnrichNode.vue +37 -0
- package/front/src/demo/FlowKitchenSink.vue +165 -0
- package/front/src/demo/OutputNode.vue +45 -0
- package/front/src/demo/SourceNode.vue +49 -0
- package/front/src/demo/calculation/AddNode.vue +35 -0
- package/front/src/demo/calculation/ConstantNode.vue +61 -0
- package/front/src/demo/calculation/FlowCalculationDemo.vue +300 -0
- package/front/src/demo/calculation/MultiplyNode.vue +35 -0
- package/front/src/demo/calculation/PowerNode.vue +62 -0
- package/front/src/demo/flowTestId.js +3 -0
- package/front/src/demo/runDemoGraph.js +76 -0
- package/front/src/demo/seedGraph.js +38 -0
- package/front/src/entry-client.js +2 -2
- package/front/src/entry-server.js +2 -1
- package/front/src/router.js +20 -12
- package/package.json +44 -37
- package/server/app.config.js +61 -0
- package/server/calculation/calculation.js +20 -0
- package/server/calculation/config.js +15 -0
- package/server/calculation/definition.js +11 -0
- package/server/calculation/index.js +12 -0
- package/server/calculation/ops.js +69 -0
- package/server/calculation/run.js +69 -0
- package/server/calculation/runGraph.js +85 -0
- package/server/calculation/tests/run.test.js +73 -0
- package/server/calculation/triggers.js +95 -0
- package/server/calculation/wire.js +30 -0
- package/server/init.js +1 -6
- package/server/services.list.js +11 -0
- package/server/start.js +39 -0
- package/LICENSE.md +0 -11
- 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
|
+
})
|
package/e2e/e2eSuite.ts
ADDED
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,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
|
+
}
|
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 {
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
<style>
|
|
37
|
+
@import "tailwindcss";
|
|
38
|
+
@plugin "tailwindcss-primeui";
|
|
39
39
|
|
|
40
|
-
|
|
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))
|