@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.
- package/custom-elements-manifest.config.js +2 -2
- package/dist/custom-elements.json +1910 -362
- package/dist/vscode.html-custom-data.json +55 -14
- package/dist/web-types.json +185 -32
- package/dist/zn.d.ts +684 -16
- package/dist/zn.min.js +1325 -929
- package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
- package/docs/pages/components/flow-builder.md +422 -66
- package/docs/pages/components/page-builder.md +167 -0
- package/docs/pages/components/settings-container.md +37 -2
- package/package.json +1 -1
- package/src/components/datepicker/datepicker.scss +0 -10
- package/src/components/flow-builder/flow-builder.component.ts +377 -16
- package/src/components/flow-builder/flow-builder.scss +398 -250
- package/src/components/flow-builder/flow-builder.test.ts +241 -4
- package/src/components/flow-builder/flow-layout.ts +42 -17
- package/src/components/flow-builder/flow.types.ts +168 -43
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
- package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
- package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
- package/src/components/header/header.scss +3 -1
- package/src/components/icon-picker/icon-picker.component.ts +20 -37
- package/src/components/icon-picker/icon-picker.scss +5 -45
- package/src/components/page-builder/index.ts +14 -0
- package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
- package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
- package/src/components/page-builder/page-builder.component.ts +1053 -0
- package/src/components/page-builder/page-builder.scss +494 -0
- package/src/components/page-builder/page-builder.test.ts +464 -0
- package/src/components/page-builder/page-registry.ts +48 -0
- package/src/components/page-builder/page.types.ts +75 -0
- package/src/components/panel/panel.scss +1 -0
- package/src/components/settings-container/settings-container.component.ts +74 -9
- package/src/components/settings-container/settings-container.scss +75 -0
- package/src/components/settings-container/settings-container.test.ts +35 -0
- package/src/events/events.ts +2 -0
- package/src/events/zn-page-change.ts +9 -0
- package/src/events/zn-page-selection-change.ts +7 -0
- package/src/zinc.ts +4 -0
- 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
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
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
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 },
|