@kubex/zinc 1.1.95 → 1.1.96
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/custom-elements.json +193 -428
- package/dist/vscode.html-custom-data.json +1 -1
- package/dist/web-types.json +3 -3
- package/dist/zn.d.ts +113 -224
- package/dist/zn.min.js +331 -377
- package/docs/pages/components/flow-builder.md +17 -1
- package/docs/pages/components/page-builder.md +21 -92
- package/package.json +1 -1
- package/src/components/flow-builder/flow-builder.component.ts +16 -1
- package/src/components/flow-builder/flow-builder.test.ts +206 -1
- package/src/components/flow-builder/flow-geometry.test.ts +102 -0
- package/src/components/flow-builder/flow.types.ts +82 -15
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +163 -108
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -0
- package/src/components/page-builder/page-builder.component.ts +229 -429
- package/src/components/page-builder/page-builder.scss +10 -64
- package/src/components/page-builder/page-builder.test.ts +162 -614
- package/src/components/page-builder/page.types.ts +7 -75
- package/src/components/remarkd-editor/remarkd-editor.component.ts +37 -11
- package/src/components/remarkd-editor/remarkd-editor.test.ts +79 -0
- package/src/components/page-builder/page-tree.test.ts +0 -483
- package/src/components/page-builder/page-tree.ts +0 -329
|
@@ -11,7 +11,12 @@ The Flow Builder is a three-panel editor for visual automations, with an optiona
|
|
|
11
11
|
- **Header** (optional) — a full-width action bar rendered only when `slot="header-left"` / `slot="header-right"`
|
|
12
12
|
content is provided (e.g. Close / Undo All Changes on the left, Apply Changes on the right).
|
|
13
13
|
- **Left panel** — the steps panel (searchable, tabbed by step group).
|
|
14
|
-
- **Canvas** (center) — a pannable, zoomable surface.
|
|
14
|
+
- **Canvas** (center) — a pannable, zoomable surface. A node's branches fan out along a bus below it,
|
|
15
|
+
spaced by what they actually hold: two branches with children keep a full card lane apart, while a
|
|
16
|
+
branch that is only a pill sits close in, so declaring branches you have not wired costs little room.
|
|
17
|
+
Wires never run through a card: a branch that reaches past the next row keeps its pill on its own bus
|
|
18
|
+
and takes its wire out around the outside of whatever it flies over, so it always reads as ending where
|
|
19
|
+
it ends. Connections are **append-only**: click any **output port**
|
|
15
20
|
on a node to start a **stray branch** — the wire follows your cursor until you click a node (or its output
|
|
16
21
|
port) to attach it. Fan-in is allowed, and so are **loops** — a branch may point back to an earlier step (e.g.
|
|
17
22
|
an answer that restarts the questioning); only wiring a node directly to itself is refused. Cancel by clicking
|
|
@@ -359,6 +364,12 @@ they're declarations that drive the rendered panel):
|
|
|
359
364
|
trigger with no input.
|
|
360
365
|
- `outputs` — a JSON array of ports, each a string id or a `{"id","label"}` object (e.g.
|
|
361
366
|
`outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'`). Omit for a single default output.
|
|
367
|
+
- `fixed-outputs` — the declared outputs are the *only* branches this step has: their pills carry no
|
|
368
|
+
delete, and clicking a fully-wired node's output adds nothing. Use it when the branches mirror fixed
|
|
369
|
+
fields in your own model (a success / failure / skip triple, say), where an extra or missing branch has
|
|
370
|
+
nowhere to be stored. Branches a step declares but has not wired cost almost no room — they are spaced
|
|
371
|
+
by their pill rather than by a whole child lane — so a step can offer every branch it supports without
|
|
372
|
+
fanning empty lanes across the canvas.
|
|
362
373
|
|
|
363
374
|
Set an optional per-tab hint with the `triggers-hint` / `actions-hint` / `rules-hint` attributes on the
|
|
364
375
|
builder. (A node's inspector `renderConfig` can't be expressed in markup — supply it via
|
|
@@ -604,6 +615,11 @@ builder.registerNodeTypes([
|
|
|
604
615
|
A node type with `inputs: []` is a starting point (a trigger) with no incoming port. A type with
|
|
605
616
|
multiple `outputs` renders one labeled port per output, so a branch can fan out to different steps.
|
|
606
617
|
|
|
618
|
+
Add **`fixedOutputs: true`** when those outputs are the only branches the type can have — the canvas
|
|
619
|
+
drops the delete on their pills and refuses to materialise an extra one. Unwired branches are spaced by
|
|
620
|
+
their pill rather than a child lane, so a type can declare its full set of branches up front without the
|
|
621
|
+
canvas fanning empty lanes for the ones a given node is not using.
|
|
622
|
+
|
|
607
623
|
#### User-configurable outputs
|
|
608
624
|
|
|
609
625
|
A type's `outputs` are the default. For steps whose branches are defined by the user (e.g. a conditional
|
|
@@ -62,24 +62,13 @@ toggle to opt out.
|
|
|
62
62
|
|
|
63
63
|
Style the panel through `inspector`, `inspector-header` and `inspector-body` parts.
|
|
64
64
|
|
|
65
|
-
A container section additionally gets a **Layout** group at the top of the inspector: a column
|
|
66
|
-
count, a weight per column, a "Keep adding rows" toggle, and — when growing is off — a row count.
|
|
67
|
-
Every edit here is lossless: changing the column count re-chunks the same ordered list of
|
|
68
|
-
stacks into the new shape, changing the row count only pads or trims trailing empty rows
|
|
69
|
-
(clamping at the last row that still holds content), and changing a column's weight leaves
|
|
70
|
-
every cell's contents untouched. All of it is undoable like any other edit.
|
|
71
|
-
|
|
72
65
|
## JavaScript API
|
|
73
66
|
|
|
74
67
|
- `state` — get/set the current `PageState`. The getter returns a deep copy; the setter
|
|
75
68
|
replaces the state wholesale (like the `config` attribute, it does not emit `zn-page-change`).
|
|
76
69
|
- `addSection(type, index?)` — insert a new section of a registered type (default: at the end).
|
|
77
|
-
- `
|
|
78
|
-
|
|
79
|
-
row-major `cells` list; `insertIndex` (default `0`, the top of the stack) is where in that
|
|
80
|
-
cell's stack it lands. Returns the new section, or `null` if the drop isn't allowed — because
|
|
81
|
-
the type isn't in the container's `accepts` list, or because it would exceed the two-level
|
|
82
|
-
nesting cap.
|
|
70
|
+
- `addSectionToSlot(type, containerId, slotIndex)` — insert into a container's empty slot,
|
|
71
|
+
honouring its `accepts` list. Returns the new section, or `null` if not allowed.
|
|
83
72
|
- `undo()` / `redo()` — step through edit history (bounded at 50 entries). There is no built-in
|
|
84
73
|
toolbar: wire these to your own header buttons or keyboard shortcuts.
|
|
85
74
|
- `registerSectionType(type)` / `registerSectionTypes(types)` — programmatic registration,
|
|
@@ -125,8 +114,8 @@ or call `restoreAutoSave()` yourself.
|
|
|
125
114
|
|
|
126
115
|
Every edit emits `zn-page-change` with the full page state (`event.detail.state`, also
|
|
127
116
|
readable via the `state` property) — plain JSON the host persists and later feeds back in
|
|
128
|
-
through the `config` attribute. Sections appear in page order;
|
|
129
|
-
|
|
117
|
+
through the `config` attribute. Sections appear in page order; container sections carry a
|
|
118
|
+
`children` array sized to their slot count, with `null` for empty slots:
|
|
130
119
|
|
|
131
120
|
```json
|
|
132
121
|
{
|
|
@@ -141,11 +130,10 @@ carries `layout` and `cells`, per the Containers section below:
|
|
|
141
130
|
"type": "article-grid",
|
|
142
131
|
"label": "Popular articles",
|
|
143
132
|
"data": {"title": "Popular"},
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
[]
|
|
133
|
+
"children": [
|
|
134
|
+
{"id": "s-mc42h-2", "type": "article-tile", "data": {"article": "art_42"}},
|
|
135
|
+
{"id": "s-mc42p-3", "type": "article-tile", "data": {"article": "art_7"}},
|
|
136
|
+
null, null, null, null
|
|
149
137
|
]
|
|
150
138
|
},
|
|
151
139
|
{
|
|
@@ -157,10 +145,6 @@ carries `layout` and `cells`, per the Containers section below:
|
|
|
157
145
|
}
|
|
158
146
|
```
|
|
159
147
|
|
|
160
|
-
A host loading a config saved before this model — the old flat, null-padded `children` array —
|
|
161
|
-
still has it accepted and migrated on load; see "Legacy `slots`" further down for what that
|
|
162
|
-
older shape looked like and how it's converted.
|
|
163
|
-
|
|
164
148
|
## A required first section
|
|
165
149
|
|
|
166
150
|
Pages that must always open with a particular section — a hero banner, a masthead — set
|
|
@@ -189,87 +173,32 @@ nothing about the lock is written into the persisted config.
|
|
|
189
173
|
```
|
|
190
174
|
|
|
191
175
|
The config above declares only a rich-text section, so the hero is inserted above it — loading a
|
|
192
|
-
page that lacks the required section normalises it rather than rejecting it.
|
|
193
|
-
|
|
194
|
-
prefilled should put it into the `config` it hands over rather than rely on the insert. And the
|
|
195
|
-
guard is client-side, so a host that persists the config should enforce the same rule on save.
|
|
196
|
-
|
|
197
|
-
## Containers
|
|
198
|
-
|
|
199
|
-
A section type with the `container` attribute becomes a full-row container: its card renders a
|
|
200
|
-
grid of **cells** beneath it, and each cell holds an ordered **stack** of sections. The type
|
|
201
|
-
author only declares that it's a container and what it starts as; the editor reshapes it after
|
|
202
|
-
placing it, from the inspector's Layout group.
|
|
203
|
-
|
|
204
|
-
| Attribute | Meaning |
|
|
205
|
-
|---|---|
|
|
206
|
-
| `container` | Marks the type a container. Required. |
|
|
207
|
-
| `columns="4"` | Seeds a new instance with 4 equal columns. |
|
|
208
|
-
| `widths="1 2 1"` | Seeds the column weights directly (comma- or whitespace-separated). Wins over `columns`. |
|
|
209
|
-
| `grow` | Seeds the instance growable — it always offers a further empty row. |
|
|
210
|
-
| `accepts="a,b"` | Restricts which types the cells take. Omit to allow any type, within the nesting cap below. |
|
|
176
|
+
page that lacks the required section normalises it rather than rejecting it. The guard is
|
|
177
|
+
client-side, so a host that persists the config should still enforce the same rule on save.
|
|
211
178
|
|
|
212
|
-
|
|
213
|
-
columns — the container falls back to `columns`, and if that's absent too, to three equal
|
|
214
|
-
columns (`[1, 1, 1]`).
|
|
179
|
+
## Container tiles
|
|
215
180
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
181
|
+
A section type with a `slots` attribute becomes a full-row container: its card renders a
|
|
182
|
+
3-column grid of that many child slots beneath it. Drag sections from the palette into empty
|
|
183
|
+
cells, drag children **between cells to reorder**, or out onto the page. `accepts` restricts
|
|
184
|
+
which types the slots take. Containers can't be placed inside other containers, and slot
|
|
185
|
+
contents persist as `children` on the section (empty slots are `null`).
|
|
221
186
|
|
|
222
187
|
```html:preview
|
|
223
188
|
<zn-page-builder heading="KB Homepage" style="height: 560px"
|
|
224
|
-
config='{"sections":[{"id":"g1","type":"
|
|
225
|
-
<template type="
|
|
226
|
-
description="A
|
|
227
|
-
|
|
228
|
-
</template>
|
|
229
|
-
<template type="grid" slot="config" label="Tile Grid" icon="grid_view" category="Layout"
|
|
230
|
-
description="Keeps adding rows as you fill it" container columns="3" grow>
|
|
189
|
+
config='{"sections":[{"id":"g1","type":"article-grid","data":{}}]}'>
|
|
190
|
+
<template type="article-grid" slot="config" label="Article Grid" icon="grid_view"
|
|
191
|
+
category="Layout" description="A 3x2 grid of article tiles"
|
|
192
|
+
slots="6" accepts="article-tile">
|
|
231
193
|
<zn-input name="title" label="Grid title"></zn-input>
|
|
232
194
|
</template>
|
|
233
|
-
<template type="article" slot="config" label="Article" icon="article" category="Content"
|
|
195
|
+
<template type="article-tile" slot="config" label="Article" icon="article" category="Content"
|
|
234
196
|
description="A single article tile">
|
|
235
197
|
<zn-input name="article" label="Article id"></zn-input>
|
|
236
198
|
</template>
|
|
237
199
|
</zn-page-builder>
|
|
238
200
|
```
|
|
239
201
|
|
|
240
|
-
### The container config
|
|
241
|
-
|
|
242
|
-
A container persists its `layout` and its `cells` — a flat, row-major list of stacks whose
|
|
243
|
-
length is always a whole multiple of `layout.widths.length`. Rows are implicit
|
|
244
|
-
(`cells.length / layout.widths.length`), so there's no row count to keep in sync:
|
|
245
|
-
|
|
246
|
-
```json
|
|
247
|
-
{
|
|
248
|
-
"id": "s-mc42a-1",
|
|
249
|
-
"type": "row",
|
|
250
|
-
"layout": { "widths": [1, 2, 1], "grow": false },
|
|
251
|
-
"cells": [
|
|
252
|
-
[ { "id": "s-1", "type": "nav", "data": {} },
|
|
253
|
-
{ "id": "s-2", "type": "links", "data": {} } ],
|
|
254
|
-
[ { "id": "s-3", "type": "hero", "data": {} } ],
|
|
255
|
-
[]
|
|
256
|
-
]
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
Render it by mapping each weight to a grid track and each cell to a stack. A **growable**
|
|
261
|
-
container never persists a trailing all-empty row — the builder adds that row itself at render
|
|
262
|
-
time, so don't expect it in the JSON. A **fixed** container's trailing empty row, by contrast, is
|
|
263
|
-
part of its layout and does persist.
|
|
264
|
-
|
|
265
|
-
### Legacy `slots`
|
|
266
|
-
|
|
267
|
-
`slots="6"` still declares a container, and pages persisted with the old flat, null-padded
|
|
268
|
-
`children` array still load: they're migrated to `layout` + `cells` on load and re-saved in the
|
|
269
|
-
new shape — `children` is never written back. A `slots`-declared container that has no explicit
|
|
270
|
-
`accepts` also keeps its older, stricter rule of refusing container types in its cells (rather
|
|
271
|
-
than allowing anything up to the nesting cap). Prefer `container` going forward.
|
|
272
|
-
|
|
273
202
|
## List sections
|
|
274
203
|
|
|
275
204
|
Sections that show a set of existing items (categories, articles, …) reference them by id:
|
package/package.json
CHANGED
|
@@ -493,6 +493,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
493
493
|
description: el.getAttribute('description') ?? undefined,
|
|
494
494
|
inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
|
|
495
495
|
outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
|
|
496
|
+
fixedOutputs: el.hasAttribute('fixed-outputs'),
|
|
496
497
|
branchFilters: ZnFlowBuilder._parseBranchFilters(el),
|
|
497
498
|
};
|
|
498
499
|
}
|
|
@@ -810,10 +811,20 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
810
811
|
// Centred under the branch drop (where the pill hangs), a full layer below
|
|
811
812
|
// the source (same rhythm as untangle) — the child lands in a straight
|
|
812
813
|
// line under the branch instead of being shoved sideways by collision.
|
|
813
|
-
const x = branchDropXs(source, t => this.registry.get(t))[idx];
|
|
814
|
+
const x = branchDropXs(source, t => this.registry.get(t), this._state.connections)[idx];
|
|
814
815
|
return {x: Math.round(x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
|
|
815
816
|
}
|
|
816
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Whether a node refuses the branch being asked of it: a fixed-output type
|
|
820
|
+
* has only the branches it declares, so the "new branch" sentinel has nowhere
|
|
821
|
+
* to go. Checked before the history push, so a refused gesture is a no-op
|
|
822
|
+
* rather than an empty undo step.
|
|
823
|
+
*/
|
|
824
|
+
private _refusesBranch(node: FlowNodeInstance, port: string): boolean {
|
|
825
|
+
return port === NEW_OUTPUT_PORT && !!this.registry.get(node.type)?.fixedOutputs;
|
|
826
|
+
}
|
|
827
|
+
|
|
817
828
|
/**
|
|
818
829
|
* Resolve an output port id on a node: the "new branch" sentinel materialises a
|
|
819
830
|
* fresh, labelled output port (per-instance), so it exists before connecting.
|
|
@@ -847,6 +858,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
847
858
|
if (!source || !type) return;
|
|
848
859
|
const inPort = typeInputs(type)[0]?.id;
|
|
849
860
|
if (!inPort) return; // an entrypoint takes no inputs, so it can't be attached
|
|
861
|
+
if (this._refusesBranch(source, port)) return;
|
|
850
862
|
this._pushHistory();
|
|
851
863
|
port = this._ensureOutput(source, port);
|
|
852
864
|
this._ensureBranchLabel(source, port);
|
|
@@ -882,6 +894,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
882
894
|
if (!source || !target || sourceId === targetId) return;
|
|
883
895
|
const inPort = firstInputId(target, this.registry.get(target.type));
|
|
884
896
|
if (inPort === null) return;
|
|
897
|
+
if (this._refusesBranch(source, port)) return;
|
|
885
898
|
this._pushHistory();
|
|
886
899
|
port = this._ensureOutput(source, port);
|
|
887
900
|
this._ensureBranchLabel(source, port);
|
|
@@ -970,6 +983,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
970
983
|
private _onBranchDelete = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
971
984
|
const node = this._state.nodes.find(n => n.id === e.detail.nodeId);
|
|
972
985
|
if (!node) return;
|
|
986
|
+
if (this.registry.get(node.type)?.fixedOutputs) return; // its branches are the only ones it has
|
|
973
987
|
this._pushHistory();
|
|
974
988
|
const outputs = nodeOutputs(node, this.registry.get(node.type)).filter(p => p.id !== e.detail.port);
|
|
975
989
|
// Deleting the last branch leaves a plain open output, so the node stays extensible.
|
|
@@ -1041,6 +1055,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1041
1055
|
if (!owner || !moving) return;
|
|
1042
1056
|
const inPort = firstInputId(moving, this.registry.get(moving.type));
|
|
1043
1057
|
if (inPort === null) return;
|
|
1058
|
+
if (this._refusesBranch(owner, e.detail.port)) return;
|
|
1044
1059
|
|
|
1045
1060
|
this._pushHistory();
|
|
1046
1061
|
const outPort = this._ensureOutput(owner, e.detail.port);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
2
|
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
-
import type
|
|
3
|
+
import {type FlowNodeType, type FlowState, NEW_OUTPUT_PORT, NODE_HEIGHT, NODE_WIDTH} from './flow.types';
|
|
4
4
|
import type ZnFlowBuilder from './flow-builder.component';
|
|
5
5
|
|
|
6
6
|
const TRIGGER: FlowNodeType = {type: 'webhook', label: 'Webhook', group: 'trigger', category: 'Contacts'};
|
|
@@ -293,4 +293,209 @@ describe('<zn-flow-builder>', () => {
|
|
|
293
293
|
]);
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
describe('fixed outputs', () => {
|
|
297
|
+
const FIXED: FlowNodeType = {
|
|
298
|
+
type: 'step',
|
|
299
|
+
label: 'Step',
|
|
300
|
+
group: 'action',
|
|
301
|
+
fixedOutputs: true,
|
|
302
|
+
outputs: [
|
|
303
|
+
{id: 'success', label: 'Success'},
|
|
304
|
+
{id: 'failure', label: 'Failure'},
|
|
305
|
+
{id: 'skip', label: 'Skip'},
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const wiredState: FlowState = {
|
|
310
|
+
nodes: [
|
|
311
|
+
{id: 'n1', type: 'step', x: 0, y: 0, data: {}},
|
|
312
|
+
{id: 'n2', type: 'step', x: 0, y: 220, data: {}},
|
|
313
|
+
],
|
|
314
|
+
connections: [
|
|
315
|
+
{id: 'c1', source: {node: 'n1', port: 'success'}, target: {node: 'n2', port: 'in'}},
|
|
316
|
+
{id: 'c2', source: {node: 'n1', port: 'failure'}, target: {node: 'n2', port: 'in'}},
|
|
317
|
+
{id: 'c3', source: {node: 'n1', port: 'skip'}, target: {node: 'n2', port: 'in'}},
|
|
318
|
+
],
|
|
319
|
+
notes: [],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const makeFixed = async (state: FlowState) => {
|
|
323
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
324
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
325
|
+
el.registerNodeTypes([FIXED]);
|
|
326
|
+
el.setState(state);
|
|
327
|
+
await el.updateComplete;
|
|
328
|
+
return el;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
it('should refuse to delete one of a fixed type\'s branches', async () => {
|
|
332
|
+
const el = await makeFixed(wiredState);
|
|
333
|
+
el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
|
|
334
|
+
await el.updateComplete;
|
|
335
|
+
|
|
336
|
+
const node = el.getState().nodes[0];
|
|
337
|
+
expect(node.outputs ?? FIXED.outputs).to.have.length(3);
|
|
338
|
+
expect(el.getState().connections).to.have.length(3);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should not offer a delete on a fixed type\'s branch pills', async () => {
|
|
342
|
+
const el = await makeFixed(wiredState);
|
|
343
|
+
const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
|
|
344
|
+
expect(canvas?.shadowRoot?.querySelector('.branch-pill')).to.exist;
|
|
345
|
+
expect(canvas?.shadowRoot?.querySelector('.branch-pill-delete')).to.not.exist;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should not grow a fixed type an extra branch when every one is wired', async () => {
|
|
349
|
+
const el = await makeFixed(wiredState);
|
|
350
|
+
el.dispatchEvent(new CustomEvent('flow-link-assign', {
|
|
351
|
+
detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
|
|
352
|
+
}));
|
|
353
|
+
await el.updateComplete;
|
|
354
|
+
|
|
355
|
+
const node = el.getState().nodes[0];
|
|
356
|
+
expect(node.outputs ?? FIXED.outputs).to.have.length(3);
|
|
357
|
+
expect(el.getState().connections).to.have.length(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should still let an ordinary type grow a branch', async () => {
|
|
361
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
362
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
363
|
+
el.registerNodeTypes([{...FIXED, fixedOutputs: false}]);
|
|
364
|
+
el.setState(wiredState);
|
|
365
|
+
await el.updateComplete;
|
|
366
|
+
|
|
367
|
+
el.dispatchEvent(new CustomEvent('flow-link-assign', {
|
|
368
|
+
detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
|
|
369
|
+
}));
|
|
370
|
+
await el.updateComplete;
|
|
371
|
+
|
|
372
|
+
expect(el.getState().nodes[0].outputs).to.have.length(4);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should read fixed-outputs off a declared step', async () => {
|
|
376
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
377
|
+
<zn-flow-builder>
|
|
378
|
+
<zn-flow-step type="step" group="action" label="Step" fixed-outputs
|
|
379
|
+
outputs='[{"id":"success","label":"Success"},{"id":"failure","label":"Failure"}]'>
|
|
380
|
+
</zn-flow-step>
|
|
381
|
+
</zn-flow-builder>`);
|
|
382
|
+
el.setState({
|
|
383
|
+
nodes: [{id: 'n1', type: 'step', x: 0, y: 0, data: {}}],
|
|
384
|
+
connections: [],
|
|
385
|
+
notes: [],
|
|
386
|
+
});
|
|
387
|
+
await el.updateComplete;
|
|
388
|
+
|
|
389
|
+
el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
|
|
390
|
+
await el.updateComplete;
|
|
391
|
+
expect(el.getState().nodes[0].outputs).to.be.undefined; // untouched: still the type's two
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
describe('wire routing', () => {
|
|
395
|
+
const TRIPLE: FlowNodeType = {
|
|
396
|
+
type: 'step',
|
|
397
|
+
label: 'Step',
|
|
398
|
+
group: 'action',
|
|
399
|
+
fixedOutputs: true,
|
|
400
|
+
outputs: [
|
|
401
|
+
{id: 'success', label: 'Success'},
|
|
402
|
+
{id: 'failure', label: 'Failure'},
|
|
403
|
+
{id: 'skip', label: 'Skip'},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// A's failure skips the middle row entirely and lands on C, while A's other
|
|
408
|
+
// branches and B's success feed the rows in between — the arrangement that
|
|
409
|
+
// drew A's failure wire straight down through B, so it read as though the
|
|
410
|
+
// branch ended at B.
|
|
411
|
+
const SKIP_A_ROW: FlowState = {
|
|
412
|
+
nodes: [
|
|
413
|
+
{id: 'a', type: 'step', x: 200, y: 700, data: {}},
|
|
414
|
+
{id: 'b', type: 'step', x: 200, y: 920, data: {}},
|
|
415
|
+
{id: 'c', type: 'step', x: 200, y: 1140, data: {}},
|
|
416
|
+
],
|
|
417
|
+
connections: [
|
|
418
|
+
{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}},
|
|
419
|
+
{id: 'c2', source: {node: 'a', port: 'skip'}, target: {node: 'b', port: 'in'}},
|
|
420
|
+
{id: 'c3', source: {node: 'a', port: 'failure'}, target: {node: 'c', port: 'in'}},
|
|
421
|
+
{id: 'c4', source: {node: 'b', port: 'success'}, target: {node: 'c', port: 'in'}},
|
|
422
|
+
],
|
|
423
|
+
notes: [],
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const points = (d: string) => {
|
|
427
|
+
const numbers = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
|
428
|
+
const out: { x: number; y: number }[] = [];
|
|
429
|
+
for (let i = 0; i + 1 < numbers.length; i += 2) out.push({x: numbers[i], y: numbers[i + 1]});
|
|
430
|
+
return out;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const wirePaths = async (state: FlowState) => {
|
|
434
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
435
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
436
|
+
el.registerNodeTypes([TRIPLE]);
|
|
437
|
+
el.setState(state);
|
|
438
|
+
await el.updateComplete;
|
|
439
|
+
const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
|
|
440
|
+
await canvas?.updateComplete;
|
|
441
|
+
return Array.from(canvas?.shadowRoot?.querySelectorAll('path.wire--link') ?? [])
|
|
442
|
+
.map(p => points(p.getAttribute('d') ?? ''))
|
|
443
|
+
.filter(pts => pts.length > 1);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Segments are orthogonal, so a crossing is a plain rect overlap. The card's
|
|
447
|
+
// own edges are fair game: wires legitimately end on the top one.
|
|
448
|
+
const crossesCard = (pts: { x: number; y: number }[], card: { x: number; y: number }) => {
|
|
449
|
+
const E = 1;
|
|
450
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
451
|
+
const minX = Math.min(pts[i].x, pts[i + 1].x);
|
|
452
|
+
const maxX = Math.max(pts[i].x, pts[i + 1].x);
|
|
453
|
+
const minY = Math.min(pts[i].y, pts[i + 1].y);
|
|
454
|
+
const maxY = Math.max(pts[i].y, pts[i + 1].y);
|
|
455
|
+
if (minX < card.x + NODE_WIDTH - E && maxX > card.x + E
|
|
456
|
+
&& minY < card.y + NODE_HEIGHT - E && maxY > card.y + E) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
it('should route a row-skipping wire around the card it flies over', async () => {
|
|
464
|
+
const paths = await wirePaths(SKIP_A_ROW);
|
|
465
|
+
const middle = {x: 200, y: 920};
|
|
466
|
+
|
|
467
|
+
expect(paths.length).to.be.greaterThan(0);
|
|
468
|
+
paths.forEach(pts => {
|
|
469
|
+
expect(crossesCard(pts, middle), `wire ${JSON.stringify(pts)} crosses the middle card`)
|
|
470
|
+
.to.equal(false);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should take the row-skipping wire out to the side, not through the column', async () => {
|
|
475
|
+
const paths = await wirePaths(SKIP_A_ROW);
|
|
476
|
+
// A's failure pill sits on A's bus, so its wire is the one leaving highest.
|
|
477
|
+
const skipping = paths.reduce((lowestY, pts) => (pts[0].y < lowestY[0].y ? pts : lowestY));
|
|
478
|
+
|
|
479
|
+
expect(skipping[skipping.length - 1].y).to.equal(1140); // ends on C's input
|
|
480
|
+
expect(skipping.length).to.be.greaterThan(2); // not the straight drop
|
|
481
|
+
// It leaves the corridor the cards occupy (200..440) on its way past.
|
|
482
|
+
const outside = skipping.some(p => p.x > 440 || p.x < 200);
|
|
483
|
+
expect(outside, `expected a detour outside the column, got ${JSON.stringify(skipping)}`).to.equal(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should still draw a straight wire when the corridor is clear', async () => {
|
|
487
|
+
const paths = await wirePaths({
|
|
488
|
+
nodes: [
|
|
489
|
+
{id: 'a', type: 'step', x: 200, y: 700, data: {}},
|
|
490
|
+
{id: 'b', type: 'step', x: 200, y: 920, data: {}},
|
|
491
|
+
],
|
|
492
|
+
connections: [{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}}],
|
|
493
|
+
notes: [],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(paths).to.have.length(1);
|
|
497
|
+
expect(paths[0]).to.have.length(2);
|
|
498
|
+
expect(paths[0][0].x).to.equal(paths[0][1].x);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
296
501
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BRANCH_SPREAD,
|
|
3
|
+
branchDropXs,
|
|
4
|
+
branchPillTop,
|
|
5
|
+
BUS_OFFSET,
|
|
6
|
+
type FlowConnection,
|
|
7
|
+
type FlowNodeInstance,
|
|
8
|
+
type FlowNodeType,
|
|
9
|
+
NODE_HEIGHT,
|
|
10
|
+
NODE_WIDTH,
|
|
11
|
+
} from './flow.types';
|
|
12
|
+
import {expect} from '@open-wc/testing';
|
|
13
|
+
|
|
14
|
+
const TRIPLE: FlowNodeType = {
|
|
15
|
+
type: 'step',
|
|
16
|
+
label: 'Step',
|
|
17
|
+
group: 'action',
|
|
18
|
+
outputs: [
|
|
19
|
+
{id: 'success', label: 'Success'},
|
|
20
|
+
{id: 'failure', label: 'Failure'},
|
|
21
|
+
{id: 'skip', label: 'Skip'},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const typeOf = () => TRIPLE;
|
|
26
|
+
|
|
27
|
+
const node = (id: string, x: number, y = 0): FlowNodeInstance => ({id, type: 'step', x, y, data: {}});
|
|
28
|
+
|
|
29
|
+
const wire = (from: string, port: string, to = 'child'): FlowConnection =>
|
|
30
|
+
({id: `c-${from}-${port}`, source: {node: from, port}, target: {node: to, port: 'in'}});
|
|
31
|
+
|
|
32
|
+
const centreOf = (n: FlowNodeInstance) => n.x + NODE_WIDTH / 2;
|
|
33
|
+
|
|
34
|
+
describe('flow branch geometry', () => {
|
|
35
|
+
it('should keep a fully wired fan on the classic spread', () => {
|
|
36
|
+
const source = node('n1', 400);
|
|
37
|
+
const xs = branchDropXs(source, typeOf, [
|
|
38
|
+
wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
expect(xs).to.deep.equal([
|
|
42
|
+
centreOf(source) - BRANCH_SPREAD,
|
|
43
|
+
centreOf(source),
|
|
44
|
+
centreOf(source) + BRANCH_SPREAD,
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should run a lone wired branch straight down, whatever else the step declares', () => {
|
|
49
|
+
const source = node('n1', 400);
|
|
50
|
+
const xs = branchDropXs(source, typeOf, [wire('n1', 'success')]);
|
|
51
|
+
|
|
52
|
+
// Success is wired, so it keeps the node's centre line and its child sits
|
|
53
|
+
// directly below; the two spare branches tuck in beside it.
|
|
54
|
+
expect(xs[0]).to.equal(centreOf(source));
|
|
55
|
+
expect(xs[1]).to.be.greaterThan(xs[0]);
|
|
56
|
+
expect(xs[2]).to.be.greaterThan(xs[1]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should give a spare branch its pill\'s width, not a whole child lane', () => {
|
|
60
|
+
const source = node('n1', 400);
|
|
61
|
+
const wired = branchDropXs(source, typeOf, [
|
|
62
|
+
wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
|
|
63
|
+
]);
|
|
64
|
+
const spare = branchDropXs(source, typeOf, [wire('n1', 'success')]);
|
|
65
|
+
|
|
66
|
+
// Same three branches, two of them unused: the fan collapses to well under
|
|
67
|
+
// half of what three child lanes claim.
|
|
68
|
+
expect(spare[2] - spare[0]).to.be.lessThan((wired[2] - wired[0]) / 2 + 1);
|
|
69
|
+
expect(spare[1] - spare[0]).to.be.lessThan(BRANCH_SPREAD);
|
|
70
|
+
expect(spare[2] - spare[1]).to.be.lessThan(BRANCH_SPREAD);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should centre two wired branches on the node with a spare branch declared', () => {
|
|
74
|
+
const source = node('n1', 400);
|
|
75
|
+
const xs = branchDropXs(source, typeOf, [wire('n1', 'success', 'a'), wire('n1', 'failure', 'b')]);
|
|
76
|
+
|
|
77
|
+
expect((xs[0] + xs[1]) / 2).to.equal(centreOf(source));
|
|
78
|
+
expect(xs[1] - xs[0]).to.equal(BRANCH_SPREAD);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should centre a pill along a short wire to the next row', () => {
|
|
82
|
+
const source = node('n1', 0, 0);
|
|
83
|
+
const child = node('n2', 0, NODE_HEIGHT + 160);
|
|
84
|
+
|
|
85
|
+
const top = branchPillTop(source, 40, child);
|
|
86
|
+
expect(top).to.be.greaterThan(source.y + NODE_HEIGHT);
|
|
87
|
+
expect(top).to.be.lessThan(child.y);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should keep a pill under its own node when the wire skips past a row', () => {
|
|
91
|
+
const source = node('n1', 0, 0);
|
|
92
|
+
const near = node('n2', 0, NODE_HEIGHT + 160);
|
|
93
|
+
const far = node('n3', 0, NODE_HEIGHT + 800);
|
|
94
|
+
|
|
95
|
+
// A long branch must not park its pill halfway down, in among the rows it
|
|
96
|
+
// passes — that is what stacked two nodes' pills on top of each other. It
|
|
97
|
+
// sits on the bus, leaving the gap below clear for the wire to route.
|
|
98
|
+
const top = branchPillTop(source, 40, far);
|
|
99
|
+
expect(top).to.equal(source.y + NODE_HEIGHT + BUS_OFFSET);
|
|
100
|
+
expect(top).to.be.lessThan(near.y);
|
|
101
|
+
});
|
|
102
|
+
});
|