@kubex/zinc 1.1.28 → 1.1.31

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 (52) hide show
  1. package/custom-elements-manifest.config.js +2 -2
  2. package/dist/custom-elements.json +1910 -362
  3. package/dist/vscode.html-custom-data.json +55 -14
  4. package/dist/web-types.json +185 -32
  5. package/dist/zn.d.ts +684 -16
  6. package/dist/zn.min.js +1325 -929
  7. package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
  8. package/docs/pages/components/flow-builder.md +422 -66
  9. package/docs/pages/components/page-builder.md +167 -0
  10. package/docs/pages/components/settings-container.md +37 -2
  11. package/package.json +1 -1
  12. package/src/components/datepicker/datepicker.scss +0 -10
  13. package/src/components/flow-builder/flow-builder.component.ts +377 -16
  14. package/src/components/flow-builder/flow-builder.scss +398 -250
  15. package/src/components/flow-builder/flow-builder.test.ts +241 -4
  16. package/src/components/flow-builder/flow-layout.ts +42 -17
  17. package/src/components/flow-builder/flow.types.ts +168 -43
  18. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
  19. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
  20. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
  21. package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
  22. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
  23. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
  24. package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
  25. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
  26. package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
  27. package/src/components/header/header.scss +3 -1
  28. package/src/components/icon-picker/icon-picker.component.ts +20 -37
  29. package/src/components/icon-picker/icon-picker.scss +5 -45
  30. package/src/components/page-builder/index.ts +14 -0
  31. package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
  32. package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
  33. package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
  34. package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
  35. package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
  36. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
  37. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
  38. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
  39. package/src/components/page-builder/page-builder.component.ts +1053 -0
  40. package/src/components/page-builder/page-builder.scss +494 -0
  41. package/src/components/page-builder/page-builder.test.ts +464 -0
  42. package/src/components/page-builder/page-registry.ts +48 -0
  43. package/src/components/page-builder/page.types.ts +75 -0
  44. package/src/components/panel/panel.scss +1 -0
  45. package/src/components/settings-container/settings-container.component.ts +74 -9
  46. package/src/components/settings-container/settings-container.scss +75 -0
  47. package/src/components/settings-container/settings-container.test.ts +35 -0
  48. package/src/events/events.ts +2 -0
  49. package/src/events/zn-page-change.ts +9 -0
  50. package/src/events/zn-page-selection-change.ts +7 -0
  51. package/src/zinc.ts +4 -0
  52. package/web-test-runner.config.js +6 -1
@@ -27,19 +27,39 @@ describe('<zn-flow-builder>', () => {
27
27
  expect(items?.[0].textContent).to.contain('Webhook');
28
28
  });
29
29
 
30
- it('should round-trip state through getState/setState', async () => {
30
+ it('should round-trip state through getState/setState, keeping positions', async () => {
31
31
  const el = await fixture<ZnFlowBuilder>(html`
32
32
  <zn-flow-builder></zn-flow-builder>`);
33
33
  const state: FlowState = {
34
34
  nodes: [{id: 'n1', type: 'webhook', x: 10, y: 20, data: {}}],
35
35
  connections: [],
36
- notes: [],
36
+ notes: [{id: 'note1', x: 300, y: 40, width: 220, height: 120, text: 'hi'}],
37
37
  };
38
38
  el.setState(state);
39
39
  await el.updateComplete;
40
40
 
41
- expect(el.getState().nodes).to.have.length(1);
42
- expect(el.getState().nodes[0].id).to.equal('n1');
41
+ // Save (value) then load (value =) — positional data survives the trip.
42
+ const json = el.value;
43
+ el.setState({nodes: [], connections: [], notes: []});
44
+ el.value = json;
45
+
46
+ const loaded = el.getState();
47
+ expect(loaded.nodes[0]).to.deep.include({id: 'n1', x: 10, y: 20});
48
+ expect(loaded.notes[0]).to.deep.include({x: 300, y: 40, width: 220, height: 120});
49
+ });
50
+
51
+ it('should serialize to the full state via toJSON, ready for a POST body', async () => {
52
+ const el = await fixture<ZnFlowBuilder>(html`
53
+ <zn-flow-builder heading="My Flow"></zn-flow-builder>`);
54
+ el.setState({
55
+ nodes: [{id: 'n1', type: 'webhook', x: 60, y: 80, data: {}}],
56
+ connections: [],
57
+ notes: [],
58
+ });
59
+
60
+ const body = JSON.parse(JSON.stringify(el)) as FlowState;
61
+ expect(body.nodes[0]).to.deep.include({id: 'n1', x: 60, y: 80});
62
+ expect(JSON.stringify(el)).to.equal(el.value);
43
63
  });
44
64
 
45
65
  it('should parse the value attribute as JSON', async () => {
@@ -56,4 +76,221 @@ describe('<zn-flow-builder>', () => {
56
76
  expect(() => el.undo()).to.not.throw();
57
77
  expect(el.getState().nodes).to.have.length(0);
58
78
  });
79
+
80
+ it('should tuck the side panels away via the edge chevrons', async () => {
81
+ const el = await fixture<ZnFlowBuilder>(html`
82
+ <zn-flow-builder></zn-flow-builder>`);
83
+ const builder = el.shadowRoot!.querySelector('.builder')!;
84
+ (el.shadowRoot!.querySelector('.panel-toggle--left') as HTMLButtonElement).click();
85
+ (el.shadowRoot!.querySelector('.panel-toggle--right') as HTMLButtonElement).click();
86
+ await el.updateComplete;
87
+ expect(builder.classList.contains('builder--steps-collapsed')).to.equal(true);
88
+ expect(builder.classList.contains('builder--side-collapsed')).to.equal(true);
89
+
90
+ // Selecting a node needs the inspector — the right panel comes back.
91
+ el.setState({nodes: [{id: 'n1', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []});
92
+ el.dispatchEvent(new CustomEvent('flow-node-select', {detail: {nodeId: 'n1'}}));
93
+ await el.updateComplete;
94
+ expect(builder.classList.contains('builder--side-collapsed')).to.equal(false);
95
+ expect(builder.classList.contains('builder--steps-collapsed')).to.equal(true);
96
+ });
97
+
98
+ describe('auto-save', () => {
99
+ const KEY = 'zn-flow-builder:as-test';
100
+ const STATE: FlowState = {
101
+ nodes: [{id: 'n1', type: 'webhook', x: 40, y: 80, data: {}}],
102
+ connections: [],
103
+ notes: [],
104
+ };
105
+ const tick = (ms: number) => new Promise(r => setTimeout(r, ms));
106
+
107
+ afterEach(() => localStorage.removeItem(KEY));
108
+
109
+ it('should not auto-save without the attribute', async () => {
110
+ const el = await fixture<ZnFlowBuilder>(html`
111
+ <zn-flow-builder id="as-test"></zn-flow-builder>`);
112
+ el.setState(STATE);
113
+ expect(el.autoSave).to.equal(null);
114
+ await tick(150);
115
+ expect(localStorage.getItem(KEY)).to.equal(null);
116
+ });
117
+
118
+ it('should default a bare attribute to 5 minutes', async () => {
119
+ const el = await fixture<ZnFlowBuilder>(html`
120
+ <zn-flow-builder id="as-test" auto-save></zn-flow-builder>`);
121
+ expect(el.autoSave).to.equal(5);
122
+ });
123
+
124
+ it('should save the state (with positions) on the interval', async () => {
125
+ const el = await fixture<ZnFlowBuilder>(html`
126
+ <zn-flow-builder id="as-test" auto-save="0.001"></zn-flow-builder>`);
127
+ el.setState(STATE);
128
+ await tick(150);
129
+
130
+ const saved = JSON.parse(localStorage.getItem(KEY)!) as { savedAt: number; state: FlowState };
131
+ expect(saved.savedAt).to.be.a('number');
132
+ expect(saved.state.nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80});
133
+ });
134
+
135
+ it('should show the status pill: a saved flash, then time since last save', async function (this: Mocha.Context) {
136
+ this.timeout(6000); // outlasts the 2.5s "Auto-saved" flash
137
+ const el = await fixture<ZnFlowBuilder>(html`
138
+ <zn-flow-builder id="as-test" auto-save="0.001"></zn-flow-builder>`);
139
+ expect(el.shadowRoot?.querySelector('.save-status')).to.equal(null);
140
+
141
+ el.setState(STATE);
142
+ await tick(150);
143
+ await el.updateComplete;
144
+ const status = el.shadowRoot?.querySelector('.save-status');
145
+ expect(status?.textContent).to.contain('Auto-saved');
146
+ expect(status?.classList.contains('save-status--saved')).to.equal(true);
147
+
148
+ // Slow the schedule right down; once the flash runs out, the pill
149
+ // reports how long since the last save.
150
+ el.autoSave = 60;
151
+ await tick(2700);
152
+ await el.updateComplete;
153
+ expect(el.shadowRoot?.querySelector('.save-status')?.textContent).to.contain('Last saved');
154
+ });
155
+
156
+ it('should offer to restore a differing auto-save on load', async () => {
157
+ localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE}));
158
+ const el = await fixture<ZnFlowBuilder>(html`
159
+ <zn-flow-builder id="as-test" auto-save></zn-flow-builder>`);
160
+
161
+ // Loading a different flow — the draft is offered.
162
+ el.setState({nodes: [{id: 'other', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []});
163
+ await el.updateComplete;
164
+ const banner = el.shadowRoot?.querySelector('.restore-banner');
165
+ expect(banner?.textContent).to.contain('differs from this flow');
166
+
167
+ (banner?.querySelector('.restore-banner__restore') as HTMLButtonElement).click();
168
+ await el.updateComplete;
169
+ expect(el.getState().nodes[0].id).to.equal('n1');
170
+ expect(el.shadowRoot?.querySelector('.restore-banner')).to.equal(null);
171
+
172
+ // Loading the same flow as the draft — nothing to offer.
173
+ el.setState(JSON.parse(JSON.stringify(STATE)) as FlowState);
174
+ await el.updateComplete;
175
+ expect(el.shadowRoot?.querySelector('.restore-banner')).to.equal(null);
176
+ });
177
+
178
+ it('should keep the offered draft intact across ticks, so Restore still applies it', async () => {
179
+ localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE}));
180
+ const el = await fixture<ZnFlowBuilder>(html`
181
+ <zn-flow-builder id="as-test" auto-save="0.001"></zn-flow-builder>`);
182
+ el.setState({nodes: [{id: 'other', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []});
183
+ await el.updateComplete;
184
+ expect(el.shadowRoot?.querySelector('.restore-banner')).to.not.equal(null);
185
+
186
+ // Several auto-save ticks pass while the prompt is open — the draft
187
+ // must not be overwritten by the loaded flow.
188
+ await tick(250);
189
+ const saved = JSON.parse(localStorage.getItem(KEY)!) as { state: FlowState };
190
+ expect(saved.state.nodes[0].id).to.equal('n1');
191
+
192
+ (el.shadowRoot?.querySelector('.restore-banner__restore') as HTMLButtonElement).click();
193
+ await el.updateComplete;
194
+ expect(el.getState().nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80});
195
+ });
196
+
197
+ it('should restore a fresh auto-save and purge an expired one', async () => {
198
+ const el = await fixture<ZnFlowBuilder>(html`
199
+ <zn-flow-builder id="as-test"></zn-flow-builder>`);
200
+
201
+ localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE}));
202
+ expect(el.restoreAutoSave()).to.equal(true);
203
+ expect(el.getState().nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80});
204
+
205
+ // Past the 1-day TTL: not restored, and cleaned out of storage.
206
+ localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now() - 25 * 60 * 60 * 1000, state: STATE}));
207
+ expect(el.restoreAutoSave()).to.equal(false);
208
+ expect(localStorage.getItem(KEY)).to.equal(null);
209
+ });
210
+ });
211
+
212
+ describe('branch filters declared in markup', () => {
213
+ const SPLIT_STATE: FlowState = {
214
+ nodes: [{id: 'n1', type: 'split', x: 0, y: 0, data: {}}],
215
+ connections: [],
216
+ notes: [],
217
+ };
218
+
219
+ async function makeBuilder(): Promise<ZnFlowBuilder> {
220
+ const el = await fixture<ZnFlowBuilder>(html`
221
+ <zn-flow-builder>
222
+ <zn-flow-step type="split" group="rule" label="Conditional Split"
223
+ outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'>
224
+ <zn-flow-filter id="engagement" label="Email engagement">
225
+ <zn-flow-filter-field id="count" type="number" value="2">
226
+ <zn-flow-operator>at least</zn-flow-operator>
227
+ <zn-flow-operator>at most</zn-flow-operator>
228
+ <zn-flow-unit value="days">day(s)</zn-flow-unit>
229
+ <zn-flow-unit value="months">month(s)</zn-flow-unit>
230
+ </zn-flow-filter-field>
231
+ </zn-flow-filter>
232
+ <zn-flow-filter id="lost-reason" label="Lost reason">
233
+ <zn-flow-filter-field id="reason" type="select" operators="Is equal to,Is not equal to">
234
+ <zn-flow-option value="price">Price</zn-flow-option>
235
+ </zn-flow-filter-field>
236
+ </zn-flow-filter>
237
+ </zn-flow-step>
238
+ </zn-flow-builder>`);
239
+ el.setState(SPLIT_STATE);
240
+ await el.updateComplete;
241
+ // Open the TRUE branch's editor, as clicking its pill would.
242
+ el.dispatchEvent(new CustomEvent('flow-branch-pick', {detail: {nodeId: 'n1', port: 'true'}}));
243
+ await el.updateComplete;
244
+ return el;
245
+ }
246
+
247
+ it('should feed nested <zn-flow-filter> declarations to the branch conditions editor', async () => {
248
+ const el = await makeBuilder();
249
+ const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions');
250
+ expect(editor).to.exist;
251
+ expect(editor?.filters).to.have.length(2);
252
+ expect(editor?.filters[0]).to.deep.include({id: 'engagement', label: 'Email engagement'});
253
+ expect(editor?.filters[0].fields[0].operators).to.deep.equal([{value: 'at least'}, {value: 'at most'}]);
254
+ expect(editor?.filters[0].fields[0].units).to.deep.equal(
255
+ [{value: 'days', label: 'day(s)'}, {value: 'months', label: 'month(s)'}]);
256
+ expect(editor?.filters[0].fields[0].value).to.equal(2);
257
+ expect(editor?.filters[1].fields[0].options).to.deep.equal([{value: 'price', label: 'Price'}]);
258
+ });
259
+
260
+ it('should persist saved conditions on the output port', async () => {
261
+ const el = await makeBuilder();
262
+ const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions');
263
+ const conditions = [[{filter: 'engagement', values: {count: {operator: 'at least', value: 3}}}]];
264
+ editor?.dispatchEvent(new CustomEvent('flow-conditions-save', {
265
+ bubbles: true,
266
+ composed: true,
267
+ detail: {conditions},
268
+ }));
269
+ await el.updateComplete;
270
+
271
+ const port = el.getState().nodes[0].outputs?.find(p => p.id === 'true');
272
+ expect(port?.data?.conditions).to.deep.equal(conditions);
273
+ // Saving closes the branch editor.
274
+ expect(el.shadowRoot?.querySelector('zn-flow-branch-conditions')).to.not.exist;
275
+ });
276
+
277
+ it('should parse the branch-filters JSON attribute', async () => {
278
+ const el = await fixture<ZnFlowBuilder>(html`
279
+ <zn-flow-builder>
280
+ <zn-flow-step type="split" group="rule" label="Split"
281
+ outputs='[{"id":"true","label":"TRUE"}]'
282
+ branch-filters='[{"id":"plan","label":"Plan","fields":[{"id":"name","options":["Basic","Pro"]}]}]'>
283
+ </zn-flow-step>
284
+ </zn-flow-builder>`);
285
+ el.setState(SPLIT_STATE);
286
+ await el.updateComplete;
287
+ el.dispatchEvent(new CustomEvent('flow-branch-pick', {detail: {nodeId: 'n1', port: 'true'}}));
288
+ await el.updateComplete;
289
+
290
+ const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions');
291
+ expect(editor?.filters).to.deep.equal([
292
+ {id: 'plan', label: 'Plan', fields: [{id: 'name', options: [{value: 'Basic'}, {value: 'Pro'}]}]},
293
+ ]);
294
+ });
295
+ });
59
296
  });
@@ -10,10 +10,13 @@ import {
10
10
  snapToGrid,
11
11
  } from './flow.types';
12
12
 
13
- /** Horizontal gap between node origins within a layer. */
13
+ /** Horizontal gap between node origins within a layer (= BRANCH_SPREAD, so siblings land under their drops). */
14
14
  export const LAYOUT_H_GAP = NODE_WIDTH + 80;
15
- /** Vertical gap between layers — clears the bus, a branch pill, and the wire run-in. */
16
- export const LAYOUT_V_GAP = 300;
15
+ /**
16
+ * Vertical gap between layers: a 160 card-to-card gap — a centred pill gets 60
17
+ * of wire above and below — keeping the arranged flow compact.
18
+ */
19
+ export const LAYOUT_V_GAP = 220;
17
20
  const MARGIN = 40;
18
21
 
19
22
  const avg = (ns: number[]) => ns.reduce((a, b) => a + b, 0) / ns.length;
@@ -60,6 +63,17 @@ export function untangledPositions(
60
63
  };
61
64
  nodes.forEach(n => layerFor(n.id));
62
65
 
66
+ // Secondary roots (no forward parents — e.g. a stray answer that only feeds
67
+ // into the flow) sink to just above their earliest child, instead of floating
68
+ // at the very top with a wire spanning the whole flow.
69
+ nodes.forEach(n => {
70
+ if (incoming.get(n.id)!.some(c => !backEdges.has(c))) return;
71
+ const outs = outgoing.get(n.id)!.filter(c => !backEdges.has(c));
72
+ if (!outs.length) return;
73
+ const above = Math.min(...outs.map(c => layerOf.get(c.target.node)!)) - 1;
74
+ if (above > layerOf.get(n.id)!) layerOf.set(n.id, above);
75
+ });
76
+
63
77
  const layers: FlowNodeInstance[][] = Array.from(
64
78
  {length: Math.max(...layerOf.values()) + 1},
65
79
  () => []
@@ -78,23 +92,34 @@ export function untangledPositions(
78
92
 
79
93
  // 2. Ordering: roots keep their left-to-right order; deeper layers sort by the
80
94
  // mean of their parents' order (nudged by which output port they hang from),
81
- // which keeps siblings in port order and reduces wire crossings.
95
+ // which keeps siblings in port order and reduces wire crossings. A second
96
+ // pass keys parentless nodes (secondary roots sunk beside the flow they
97
+ // join) on their children's first-pass order, so they sort next to what
98
+ // they feed instead of defaulting to the far left.
82
99
  const orderIdx = new Map<string, number>();
100
+ const parentBary = (n: FlowNodeInstance): number | null => {
101
+ // Forward parents only — loops don't influence ordering.
102
+ const ordered = incoming.get(n.id)!.filter(c => !backEdges.has(c) && orderIdx.has(c.source.node));
103
+ if (!ordered.length) return null;
104
+ return avg(ordered.map(c => {
105
+ const parent = byId.get(c.source.node)!;
106
+ const outputs = nodeOutputs(parent, typeOf(parent.type));
107
+ const count = Math.max(outputs.length, 1);
108
+ const idx = Math.max(outputs.findIndex(p => p.id === c.source.port), 0);
109
+ return orderIdx.get(parent.id)! + (idx + 1) / (count + 1) - 0.5;
110
+ }));
111
+ };
112
+ const childBary = (n: FlowNodeInstance): number | null => {
113
+ const outs = outgoing.get(n.id)!.filter(c => !backEdges.has(c) && orderIdx.has(c.target.node));
114
+ return outs.length ? avg(outs.map(c => orderIdx.get(c.target.node)!)) : null;
115
+ };
83
116
  layers[0].sort((a, b) => a.x - b.x).forEach((n, i) => orderIdx.set(n.id, i));
84
117
  for (let l = 1; l < layers.length; l++) {
85
- const bary = (n: FlowNodeInstance) => {
86
- // Forward parents only — loops don't influence ordering.
87
- const ordered = incoming.get(n.id)!.filter(c => !backEdges.has(c) && orderIdx.has(c.source.node));
88
- if (!ordered.length) return 0;
89
- return avg(ordered.map(c => {
90
- const parent = byId.get(c.source.node)!;
91
- const outputs = nodeOutputs(parent, typeOf(parent.type));
92
- const count = Math.max(outputs.length, 1);
93
- const idx = Math.max(outputs.findIndex(p => p.id === c.source.port), 0);
94
- return orderIdx.get(parent.id)! + (idx + 1) / (count + 1) - 0.5;
95
- }));
96
- };
97
- layers[l].sort((a, b) => bary(a) - bary(b)).forEach((n, i) => orderIdx.set(n.id, i));
118
+ layers[l].sort((a, b) => (parentBary(a) ?? 0) - (parentBary(b) ?? 0)).forEach((n, i) => orderIdx.set(n.id, i));
119
+ }
120
+ for (let l = 1; l < layers.length; l++) {
121
+ const key = (n: FlowNodeInstance) => parentBary(n) ?? childBary(n) ?? 0;
122
+ layers[l].sort((a, b) => key(a) - key(b)).forEach((n, i) => orderIdx.set(n.id, i));
98
123
  }
99
124
 
100
125
  // 3. Coordinates: place a layer at each node's desired x, resolving overlaps
@@ -12,6 +12,94 @@ export interface FlowPort {
12
12
  data?: Record<string, unknown>;
13
13
  }
14
14
 
15
+ /** A choice in a filter field's operator or value dropdown. */
16
+ export interface FlowFilterOption {
17
+ value: string;
18
+ /** Display text; defaults to the value. */
19
+ label?: string;
20
+ }
21
+
22
+ /** One control row of a branch filter (e.g. "[in the last ▾] [6] [month(s) ▾]"). */
23
+ export interface FlowFilterField {
24
+ id: string;
25
+ /** Leading text shown before the controls. */
26
+ label?: string;
27
+ /** The value control. Defaults to 'select' when `options` are given, else 'text'. */
28
+ type?: 'select' | 'number' | 'text';
29
+ /** Operator choices shown before the value (e.g. "at least" / "in the last" / "is equal to"). */
30
+ operators?: FlowFilterOption[];
31
+ /** Value choices for a 'select' field. */
32
+ options?: FlowFilterOption[];
33
+ /** Choices for an adjustable trailing unit dropdown (e.g. day(s) / month(s)); shown instead of `suffix`. */
34
+ units?: FlowFilterOption[];
35
+ /** Trailing unit text (e.g. "time(s)") when the unit isn't adjustable. */
36
+ suffix?: string;
37
+ placeholder?: string;
38
+ /** Initial value when the filter is added to a condition. */
39
+ value?: string | number;
40
+ }
41
+
42
+ /**
43
+ * Default display labels for well-known operator keys, used when a declaration
44
+ * provides a value but no text (e.g. `<zn-flow-operator value="gte">`).
45
+ */
46
+ const OPERATOR_LABELS: Record<string, string> = {
47
+ eq: 'Is Equal To',
48
+ neq: 'Is Not Equal To',
49
+ gt: 'Greater Than',
50
+ gte: 'Greater Than or Equal To',
51
+ lt: 'Less Than',
52
+ lte: 'Less Than or Equal To',
53
+ is: 'Is',
54
+ 'is-not': 'Is Not',
55
+ in: 'Is One Of',
56
+ 'not-in': 'Is Not One Of',
57
+ contains: 'Contains',
58
+ 'not-contains': 'Does Not Contain',
59
+ 'starts-with': 'Starts With',
60
+ 'ends-with': 'Ends With',
61
+ empty: 'Is Empty',
62
+ 'not-empty': 'Is Not Empty',
63
+ within: 'Within the Last',
64
+ before: 'Before the Last',
65
+ between: 'Between',
66
+ matches: 'Matches',
67
+ };
68
+
69
+ /** An operator's display label: its own, a well-known default for its value, else the value itself. */
70
+ export function operatorLabel(option: FlowFilterOption): string {
71
+ return option.label ?? OPERATOR_LABELS[option.value] ?? option.value;
72
+ }
73
+
74
+ /** A filter offered by the built-in branch conditions editor. */
75
+ export interface FlowBranchFilter {
76
+ id: string;
77
+ label: string;
78
+ description?: string;
79
+ fields: FlowFilterField[];
80
+ }
81
+
82
+ /** One configured condition: a filter plus its per-field operator / value / unit entries. */
83
+ export interface FlowBranchCondition {
84
+ /** The `FlowBranchFilter` id this condition uses. */
85
+ filter: string;
86
+ values: Record<string, { operator?: string; value?: string | number; unit?: string }>;
87
+ }
88
+
89
+ /**
90
+ * A branch's full condition set as persisted on the output port's
91
+ * `data.conditions`: the outer array is OR-ed, each inner group AND-ed.
92
+ */
93
+ export type FlowBranchConditions = FlowBranchCondition[][];
94
+
95
+ const NO_CONDITIONS: FlowBranchConditions = [];
96
+
97
+ /** Read the conditions persisted on an output port (`port.data.conditions`). */
98
+ export function branchConditions(port: FlowPort): FlowBranchConditions {
99
+ const raw = port.data?.conditions;
100
+ return Array.isArray(raw) ? (raw as FlowBranchConditions) : NO_CONDITIONS;
101
+ }
102
+
15
103
  /**
16
104
  * Describes a kind of node that can be placed on the canvas. Consumers register
17
105
  * these with the builder to extend it — the steps panel and inspector are driven
@@ -39,6 +127,12 @@ export interface FlowNodeType {
39
127
  outputs?: FlowPort[];
40
128
  /** Initial `data` for a freshly placed node. */
41
129
  defaultData?: Record<string, unknown>;
130
+ /**
131
+ * Filters offered by the built-in branch conditions editor for this type's
132
+ * output branches (AND/OR groups persisted on the port's `data.conditions`).
133
+ * Ignored when `renderBranchConfig` is set.
134
+ */
135
+ branchFilters?: FlowBranchFilter[];
42
136
  /** Renders the inspector body for a selected node of this type. */
43
137
  renderConfig?: (node: FlowNodeInstance, update: (data: Record<string, unknown>) => void) => TemplateResult;
44
138
  /** Renders the branch editor body (filters / conditions) for one of this type's output branches. */
@@ -129,8 +223,12 @@ export function emptyDragImage(): HTMLImageElement {
129
223
  export const NODE_WIDTH = 240;
130
224
  export const NODE_HEIGHT = 60;
131
225
 
132
- /** Horizontal spacing between the branches of a multi-output node. */
133
- export const BRANCH_SPREAD = 200;
226
+ /**
227
+ * Horizontal spacing between the branches of a multi-output node. Matches the
228
+ * untangle layer gap (card width + 80) so two sibling children fit side by side
229
+ * directly under their drops — straight downward wires, no elbows.
230
+ */
231
+ export const BRANCH_SPREAD = NODE_WIDTH + 80;
134
232
 
135
233
  // Branch geometry below a node: outputs fork from a stem onto a horizontal bus,
136
234
  // and labelled outputs drop from it into a name pill.
@@ -141,6 +239,35 @@ export const PILL_MAX_WIDTH = 240;
141
239
  /** Extra pill height per wrapped line (matches the pill's CSS line-height). */
142
240
  export const PILL_LINE_HEIGHT = 20;
143
241
 
242
+ /** Wire clearance kept between a branch pill's exit and the child card it feeds. */
243
+ const PILL_APPROACH = 20;
244
+ /** The least wire kept above a sole output's pill in a tight gap. */
245
+ const PILL_MIN_STUB = 20;
246
+
247
+ /**
248
+ * Canvas y of a branch pill's top edge. A pill on a wire to a child below is
249
+ * centred along the run from its node's bottom to the child's top — equal wire
250
+ * above and below, however long or tight. A sole output's wire is a straight
251
+ * stem with no bus to respect, so its pill may rise above the bus line to stay
252
+ * centred; fan branches stop at the bus. Open branches and loop/side wires
253
+ * keep the fixed drop below the bus.
254
+ */
255
+ export function branchPillTop(
256
+ node: Pick<FlowNodeInstance, 'y'>,
257
+ pillH: number,
258
+ child?: Pick<FlowNodeInstance, 'y'>,
259
+ soleOutput = false
260
+ ): number {
261
+ const bottom = node.y + NODE_HEIGHT;
262
+ const busY = bottom + BUS_OFFSET;
263
+ if (child && child.y >= bottom) {
264
+ const minTop = soleOutput ? bottom + PILL_MIN_STUB : busY;
265
+ const centered = Math.round((bottom + child.y) / 2 - pillH / 2);
266
+ return Math.max(minTop, Math.min(centered, child.y - PILL_APPROACH - pillH));
267
+ }
268
+ return busY + PILL_DROP;
269
+ }
270
+
144
271
  /**
145
272
  * Estimated pill box for a branch name: sizes to the text up to the max width,
146
273
  * then hard-wraps — the height grows a grid unit per extra line. The pill DOM
@@ -167,6 +294,13 @@ export function snapToGrid(v: number): number {
167
294
  // while pills allow tighter packing.
168
295
  const CARD_MARGIN = 10;
169
296
  const PILL_MARGIN = 4;
297
+ /**
298
+ * Extra height a pill's footprint claims below itself: room for the wire "+"
299
+ * between the pill and its child's input port. Keeps drags from compressing a
300
+ * branch past the point where the "+" fits (with the grid, the tightest
301
+ * card-to-card gap across a pill becomes 120).
302
+ */
303
+ const PILL_PLUS_ROOM = 20;
170
304
 
171
305
  interface Rect {
172
306
  x: number;
@@ -187,47 +321,17 @@ function rectsOverlap(a: Rect, b: Rect): boolean {
187
321
  export type FlowTypeOf = (type: string) => FlowNodeType | undefined;
188
322
 
189
323
  /**
190
- * Canvas x for each of a node's branch drops. A connected branch pulls straight
191
- * above its child's input a bend-free wire when it's the only wire into that
192
- * input, the child is within the branch's natural fan range, and the position
193
- * keeps clear of sibling branches; otherwise it fans out source-side.
324
+ * Canvas x for each of a node's branch drops: the natural fan position under
325
+ * the source. Pills never shift sideways to chase their child the wire into
326
+ * a pill is always a straight vertical, and any lateral offset to the child is
327
+ * taken up by the elbow below the pill (which still enters the child from
328
+ * straight above).
194
329
  */
195
- export function branchDropXs(
196
- node: FlowNodeInstance,
197
- typeOf: FlowTypeOf,
198
- nodes: FlowNodeInstance[],
199
- connections: FlowConnection[]
200
- ): number[] {
330
+ export function branchDropXs(node: FlowNodeInstance, typeOf: FlowTypeOf): number[] {
201
331
  const outputs = nodeOutputs(node, typeOf(node.type));
202
332
  const cx = node.x + NODE_WIDTH / 2;
203
- const natural = (i: number) =>
204
- Math.round(cx + (outputs.length === 1 ? 0 : (i - (outputs.length - 1) / 2) * BRANCH_SPREAD));
205
- const MIN_SEP = 170;
206
-
207
- const xs = outputs.map((port, i) => {
208
- const spread = natural(i);
209
- const conn = connections.find(c => c.source.node === node.id && c.source.port === port.id);
210
- const child = conn ? nodes.find(n => n.id === conn.target.node) : undefined;
211
- if (!conn || !child) return {x: spread, aligned: false};
212
- // Fan-in wires converge on the target — keep their pills on the source side.
213
- const fanIn = connections.some(
214
- c => c !== conn && c.target.node === conn.target.node && c.target.port === conn.target.port
215
- );
216
- if (fanIn) return {x: spread, aligned: false};
217
- const inputs = nodeInputs(child, typeOf(child.type));
218
- const idx = Math.max(inputs.findIndex(p => p.id === conn.target.port), 0);
219
- const anchor = portAnchor(child, 'in', idx, inputs.length).x;
220
- if (Math.abs(anchor - spread) > BRANCH_SPREAD) return {x: spread, aligned: false};
221
- return {x: anchor, aligned: true};
222
- });
223
-
224
- // Aligned branches yield back to their fan position rather than crowding siblings.
225
- for (let i = 0; i < xs.length; i++) {
226
- if (xs[i].aligned && xs.some((o, j) => j !== i && Math.abs(o.x - xs[i].x) < MIN_SEP)) {
227
- xs[i] = {x: natural(i), aligned: false};
228
- }
229
- }
230
- return xs.map(o => o.x);
333
+ return outputs.map((_, i) =>
334
+ Math.round(cx + (outputs.length === 1 ? 0 : (i - (outputs.length - 1) / 2) * BRANCH_SPREAD)));
231
335
  }
232
336
 
233
337
  /**
@@ -242,15 +346,17 @@ export function nodeObstacles(
242
346
  ): Rect[] {
243
347
  const rects: Rect[] = [{x: node.x, y: node.y, w: NODE_WIDTH, h: NODE_HEIGHT, m: CARD_MARGIN}];
244
348
  const ports = nodeOutputs(node, typeOf(node.type));
245
- const xs = branchDropXs(node, typeOf, nodes, connections);
349
+ const xs = branchDropXs(node, typeOf);
246
350
  ports.forEach((port, i) => {
247
351
  if (!port.label) return;
248
352
  const size = pillSize(port.label);
353
+ const conn = connections.find(c => c.source.node === node.id && c.source.port === port.id);
354
+ const child = conn ? nodes.find(n => n.id === conn.target.node) : undefined;
249
355
  rects.push({
250
356
  x: xs[i] - size.w / 2,
251
- y: node.y + NODE_HEIGHT + BUS_OFFSET + PILL_DROP,
357
+ y: branchPillTop(node, size.h, child, ports.length === 1),
252
358
  w: size.w,
253
- h: size.h,
359
+ h: size.h + PILL_PLUS_ROOM,
254
360
  m: PILL_MARGIN,
255
361
  });
256
362
  });
@@ -269,6 +375,25 @@ export function nodesCollide(
269
375
  return nodeObstacles(b, typeOf, nodes, connections).some(rb => rectsA.some(ra => rectsOverlap(ra, rb)));
270
376
  }
271
377
 
378
+ /**
379
+ * Whether any of `node`'s branch pills overlap another node's footprint. A
380
+ * pill centres between its node and the connected child, so moving the CHILD
381
+ * moves the pill — drags use this to check the moved node's parents, whose
382
+ * displaced pills could land on a third node.
383
+ */
384
+ export function pillsCollide(
385
+ node: FlowNodeInstance,
386
+ typeOf: FlowTypeOf,
387
+ nodes: FlowNodeInstance[],
388
+ connections: FlowConnection[]
389
+ ): boolean {
390
+ const pills = nodeObstacles(node, typeOf, nodes, connections).slice(1);
391
+ if (!pills.length) return false;
392
+ return nodes.some(o =>
393
+ o.id !== node.id
394
+ && nodeObstacles(o, typeOf, nodes, connections).some(rb => pills.some(ra => rectsOverlap(ra, rb))));
395
+ }
396
+
272
397
  /** Whether a bare card placed at `pos` would hit any of `node`'s footprint. */
273
398
  export function cardCollides(
274
399
  pos: { x: number; y: number },