@kubex/zinc 1.1.28 → 1.1.29
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 +1483 -17
- package/dist/vscode.html-custom-data.json +40 -1
- package/dist/web-types.json +124 -3
- package/dist/zn.d.ts +670 -14
- package/dist/zn.min.js +1305 -925
- 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/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/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/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
|
@@ -3,6 +3,7 @@ import {guard} from 'lit/directives/guard.js';
|
|
|
3
3
|
import {ifDefined} from 'lit/directives/if-defined.js';
|
|
4
4
|
import {property, state} from 'lit/decorators.js';
|
|
5
5
|
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
import ZnFlowBranchConditions from './modules/flow-branch-conditions';
|
|
6
7
|
import ZnFlowCanvas from './modules/flow-canvas';
|
|
7
8
|
import ZnFlowStepGroup from './modules/flow-step-group';
|
|
8
9
|
import ZnIcon from '../icon';
|
|
@@ -11,6 +12,8 @@ import ZnNavbar from '../navbar';
|
|
|
11
12
|
import ZnTabs from '../tabs';
|
|
12
13
|
|
|
13
14
|
import {
|
|
15
|
+
branchConditions,
|
|
16
|
+
branchDropXs,
|
|
14
17
|
cardCollides,
|
|
15
18
|
DEFAULT_OUTPUT,
|
|
16
19
|
descendantIds,
|
|
@@ -18,7 +21,11 @@ import {
|
|
|
18
21
|
emptyFlowState,
|
|
19
22
|
firstInputId,
|
|
20
23
|
FLOW_TYPE_MIME,
|
|
24
|
+
type FlowBranchConditions,
|
|
25
|
+
type FlowBranchFilter,
|
|
21
26
|
type FlowConnection,
|
|
27
|
+
type FlowFilterField,
|
|
28
|
+
type FlowFilterOption,
|
|
22
29
|
type FlowGroup,
|
|
23
30
|
type FlowNodeInstance,
|
|
24
31
|
type FlowNodeType,
|
|
@@ -31,7 +38,7 @@ import {
|
|
|
31
38
|
NODE_WIDTH,
|
|
32
39
|
nodeInputs,
|
|
33
40
|
nodeOutputs,
|
|
34
|
-
|
|
41
|
+
pillsCollide,
|
|
35
42
|
snapToGrid,
|
|
36
43
|
typeInputs,
|
|
37
44
|
typeOutputs,
|
|
@@ -45,6 +52,21 @@ import styles from './flow-builder.scss';
|
|
|
45
52
|
const HISTORY_LIMIT = 50;
|
|
46
53
|
const TYPE_MIME = FLOW_TYPE_MIME;
|
|
47
54
|
|
|
55
|
+
const AUTO_SAVE_DEFAULT_MINUTES = 5;
|
|
56
|
+
const AUTO_SAVE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
/** Compact relative time for the auto-save status ("just now", "3m ago"). */
|
|
59
|
+
function timeAgo(ms: number): string {
|
|
60
|
+
const s = Math.floor(ms / 1000);
|
|
61
|
+
if (s < 10) return 'just now';
|
|
62
|
+
if (s < 60) return `${s}s ago`;
|
|
63
|
+
const m = Math.floor(s / 60);
|
|
64
|
+
if (m < 60) return `${m}m ago`;
|
|
65
|
+
const h = Math.floor(m / 60);
|
|
66
|
+
if (h < 24) return `${h}h ago`;
|
|
67
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
const TABS: { group: FlowGroup; label: string }[] = [
|
|
49
71
|
{group: 'entrypoint', label: 'Entrypoint'},
|
|
50
72
|
{group: 'trigger', label: 'Triggers'},
|
|
@@ -64,6 +86,7 @@ interface PickerTarget { kind: 'wire'; connectionId: string }
|
|
|
64
86
|
* @dependency zn-input
|
|
65
87
|
* @dependency zn-tabs
|
|
66
88
|
* @dependency zn-navbar
|
|
89
|
+
* @dependency zn-flow-branch-conditions
|
|
67
90
|
* @dependency zn-flow-canvas
|
|
68
91
|
* @dependency zn-flow-node
|
|
69
92
|
*
|
|
@@ -72,7 +95,10 @@ interface PickerTarget { kind: 'wire'; connectionId: string }
|
|
|
72
95
|
* @event zn-flow-connect - Emitted when a connection is created. `event.detail.connection`.
|
|
73
96
|
*
|
|
74
97
|
* @slot - `<zn-flow-step>` type declarations; never displayed, each `group`/`category` routes the
|
|
75
|
-
* step into the right tab and collapsible grouping of the rendered panel.
|
|
98
|
+
* step into the right tab and collapsible grouping of the rendered panel. A step may nest
|
|
99
|
+
* `<zn-flow-filter>` declarations (each holding `<zn-flow-filter-field>`s, whose operator /
|
|
100
|
+
* option choices are nested `<zn-flow-operator>` / `<zn-flow-option>` elements) — or set a
|
|
101
|
+
* `branch-filters` JSON attribute — to drive the built-in branch conditions editor.
|
|
76
102
|
* @slot header-left - Actions shown on the left of the header bar (e.g. Close / Undo All Changes).
|
|
77
103
|
* @slot header-right - Actions shown on the right of the header bar (e.g. Apply Changes).
|
|
78
104
|
* @slot sidebar - Extra right-panel content (status, version history), below the configuration errors.
|
|
@@ -90,6 +116,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
90
116
|
'zn-input': ZnInput,
|
|
91
117
|
'zn-tabs': ZnTabs,
|
|
92
118
|
'zn-navbar': ZnNavbar,
|
|
119
|
+
'zn-flow-branch-conditions': ZnFlowBranchConditions,
|
|
93
120
|
'zn-flow-canvas': ZnFlowCanvas,
|
|
94
121
|
'zn-flow-step-group': ZnFlowStepGroup,
|
|
95
122
|
};
|
|
@@ -103,6 +130,23 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
103
130
|
/** Node ids flagged as having configuration errors (drives the red node styling). */
|
|
104
131
|
@property({attribute: false}) errorNodes: string[] = [];
|
|
105
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Auto-save the flow to localStorage (1-day TTL). Omit to disable. A bare
|
|
135
|
+
* `auto-save` saves every 5 minutes; a numeric value sets the interval in
|
|
136
|
+
* minutes (`auto-save="5"`). Restore with `restoreAutoSave()`.
|
|
137
|
+
*/
|
|
138
|
+
@property({
|
|
139
|
+
attribute: 'auto-save',
|
|
140
|
+
converter: {
|
|
141
|
+
fromAttribute: (value: string | null) => {
|
|
142
|
+
if (value === null) return null;
|
|
143
|
+
const minutes = parseFloat(value);
|
|
144
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes : AUTO_SAVE_DEFAULT_MINUTES;
|
|
145
|
+
},
|
|
146
|
+
toAttribute: (value: number | null) => (value === null ? null : String(value)),
|
|
147
|
+
},
|
|
148
|
+
}) autoSave: number | null = null;
|
|
149
|
+
|
|
106
150
|
/** Optional hint shown beneath each steps-panel tab. */
|
|
107
151
|
@property({attribute: 'entrypoints-hint'}) entrypointsHint = '';
|
|
108
152
|
@property({attribute: 'triggers-hint'}) triggersHint = '';
|
|
@@ -120,6 +164,9 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
120
164
|
@state() private _activeGroup: FlowGroup | null = null;
|
|
121
165
|
|
|
122
166
|
private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
|
|
167
|
+
/** Side panels tucked away via their edge chevrons. */
|
|
168
|
+
@state() private _stepsCollapsed = false;
|
|
169
|
+
@state() private _sideCollapsed = false;
|
|
123
170
|
/** The node being relocated via the MOVE menu action, if any. */
|
|
124
171
|
@state() private _movingNodeId: string | null = null;
|
|
125
172
|
/** The "+" picker popover target (an open output, or a wire to insert into), if open. */
|
|
@@ -175,9 +222,120 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
175
222
|
this._listeners.forEach(([name, fn]) => this.removeEventListener(name, fn));
|
|
176
223
|
document.removeEventListener('keydown', this._onKeyDown);
|
|
177
224
|
this._cancelUntangle();
|
|
225
|
+
this._stopAutoSave();
|
|
226
|
+
if (this._justSavedTimer !== null) {
|
|
227
|
+
clearTimeout(this._justSavedTimer);
|
|
228
|
+
this._justSavedTimer = null;
|
|
229
|
+
}
|
|
178
230
|
super.disconnectedCallback();
|
|
179
231
|
}
|
|
180
232
|
|
|
233
|
+
// --- Auto-save --------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
private _autoSaveTimer: number | null = null;
|
|
236
|
+
private _statusTimer: number | null = null;
|
|
237
|
+
private _justSavedTimer: number | null = null;
|
|
238
|
+
/** Guards the restore prompt from re-triggering on restoreAutoSave's own setState. */
|
|
239
|
+
private _restoring = false;
|
|
240
|
+
|
|
241
|
+
/** Epoch of the newest auto-save (also picked up from storage on start). */
|
|
242
|
+
@state() private _lastSavedAt: number | null = null;
|
|
243
|
+
/** Briefly true right after a save — flashes "Auto-saved" in the status pill. */
|
|
244
|
+
@state() private _justSaved = false;
|
|
245
|
+
/** Re-render clock for the "last saved Xm ago" label. */
|
|
246
|
+
@state() private _statusNow = Date.now();
|
|
247
|
+
/** A fresh auto-save differing from the loaded flow — offer to restore it. */
|
|
248
|
+
@state() private _restorePrompt: { savedAt: number } | null = null;
|
|
249
|
+
|
|
250
|
+
/** localStorage key for this builder's auto-saves — its id, else its heading. */
|
|
251
|
+
private get _autoSaveKey(): string {
|
|
252
|
+
return `zn-flow-builder:${this.id || this.heading || 'flow'}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The "Auto-saved" flash timeout is deliberately not cleared here — it always
|
|
256
|
+
// runs out 2.5s after the last save, even if the schedule changes meanwhile.
|
|
257
|
+
private _stopAutoSave() {
|
|
258
|
+
if (this._autoSaveTimer !== null) {
|
|
259
|
+
clearInterval(this._autoSaveTimer);
|
|
260
|
+
this._autoSaveTimer = null;
|
|
261
|
+
}
|
|
262
|
+
if (this._statusTimer !== null) {
|
|
263
|
+
clearInterval(this._statusTimer);
|
|
264
|
+
this._statusTimer = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private _restartAutoSave() {
|
|
269
|
+
this._stopAutoSave();
|
|
270
|
+
if (this.autoSave === null) return;
|
|
271
|
+
// Housekeeping: drop an expired auto-save, and carry its timestamp into the
|
|
272
|
+
// status pill when one survives — "last saved" outlives a reload.
|
|
273
|
+
const saved = this._readAutoSave();
|
|
274
|
+
if (saved) this._lastSavedAt = saved.savedAt;
|
|
275
|
+
this._autoSaveTimer = window.setInterval(this._autoSaveTick, this.autoSave * 60_000);
|
|
276
|
+
this._statusTimer = window.setInterval(() => (this._statusNow = Date.now()), 30_000);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An empty canvas is never saved — it would clobber a stored flow with nothing. */
|
|
280
|
+
private _autoSaveTick = () => {
|
|
281
|
+
// While a restore is being offered, the stored draft is the user's only
|
|
282
|
+
// copy — never overwrite it until they've decided.
|
|
283
|
+
if (this._restorePrompt) return;
|
|
284
|
+
const state = this.getState();
|
|
285
|
+
if (!state.nodes.length && !state.connections.length && !state.notes.length) return;
|
|
286
|
+
try {
|
|
287
|
+
localStorage.setItem(this._autoSaveKey, JSON.stringify({savedAt: Date.now(), state}));
|
|
288
|
+
this._lastSavedAt = Date.now();
|
|
289
|
+
this._justSaved = true;
|
|
290
|
+
if (this._justSavedTimer !== null) clearTimeout(this._justSavedTimer);
|
|
291
|
+
this._justSavedTimer = window.setTimeout(() => (this._justSaved = false), 2500);
|
|
292
|
+
} catch {
|
|
293
|
+
/* storage unavailable / full */
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** The stored auto-save, purging it when past its TTL (or unreadable). */
|
|
298
|
+
private _readAutoSave(): { savedAt: number; state: FlowState } | null {
|
|
299
|
+
try {
|
|
300
|
+
const raw = localStorage.getItem(this._autoSaveKey);
|
|
301
|
+
if (!raw) return null;
|
|
302
|
+
const saved = JSON.parse(raw) as { savedAt: number; state: FlowState };
|
|
303
|
+
if (!saved.state || Date.now() - saved.savedAt > AUTO_SAVE_TTL_MS) {
|
|
304
|
+
localStorage.removeItem(this._autoSaveKey);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
return saved;
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Load the auto-saved flow, if one exists within the 1-day TTL. */
|
|
314
|
+
restoreAutoSave(): boolean {
|
|
315
|
+
const saved = this._readAutoSave();
|
|
316
|
+
if (!saved) return false;
|
|
317
|
+
this._restoring = true;
|
|
318
|
+
try {
|
|
319
|
+
this.setState(saved.state);
|
|
320
|
+
} finally {
|
|
321
|
+
this._restoring = false;
|
|
322
|
+
}
|
|
323
|
+
this._restorePrompt = null;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A flow was just loaded — when a fresh auto-save differs from it, ask the
|
|
329
|
+
* user whether to pick up their draft instead.
|
|
330
|
+
*/
|
|
331
|
+
private _offerRestoreIfNewer() {
|
|
332
|
+
if (this.autoSave === null || this._restoring) return;
|
|
333
|
+
const saved = this._readAutoSave();
|
|
334
|
+
this._restorePrompt = saved && JSON.stringify(saved.state) !== JSON.stringify(this._state)
|
|
335
|
+
? {savedAt: saved.savedAt}
|
|
336
|
+
: null;
|
|
337
|
+
}
|
|
338
|
+
|
|
181
339
|
private _onKeyDown = (e: KeyboardEvent) => {
|
|
182
340
|
if (e.key === 'Escape' && (this._movingNodeId || this._picker || this._selectedBranch)) {
|
|
183
341
|
this._movingNodeId = null;
|
|
@@ -190,6 +348,9 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
190
348
|
if (changed.has('nodeTypes') && this.nodeTypes?.length) {
|
|
191
349
|
this.registry.registerAll(this.nodeTypes);
|
|
192
350
|
}
|
|
351
|
+
if (changed.has('autoSave')) {
|
|
352
|
+
this._restartAutoSave();
|
|
353
|
+
}
|
|
193
354
|
super.willUpdate(changed);
|
|
194
355
|
}
|
|
195
356
|
|
|
@@ -214,12 +375,116 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
214
375
|
}
|
|
215
376
|
}
|
|
216
377
|
|
|
378
|
+
/** Parse an operator / option list: a JSON array (strings or `{value,label}`) or comma-separated values. */
|
|
379
|
+
private static _parseFilterOptions(attr: string | null): FlowFilterOption[] | undefined {
|
|
380
|
+
if (attr === null) return undefined;
|
|
381
|
+
const trimmed = attr.trim();
|
|
382
|
+
if (trimmed === '') return undefined;
|
|
383
|
+
if (trimmed.startsWith('[')) {
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
386
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
387
|
+
return parsed.map(o => (typeof o === 'string' ? {value: o} : (o as FlowFilterOption)));
|
|
388
|
+
} catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return trimmed.split(',').map(s => s.trim()).filter(Boolean).map(value => ({value}));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Option list declared as child elements — the tidy form. The element's text
|
|
397
|
+
* is the label; a `value` attribute overrides the stored value:
|
|
398
|
+
* `<zn-flow-operator value="gte">at least</zn-flow-operator>`.
|
|
399
|
+
*/
|
|
400
|
+
private static _nestedFilterOptions(el: Element, tag: string): FlowFilterOption[] | undefined {
|
|
401
|
+
const els = Array.from(el.querySelectorAll(`:scope > ${tag}`));
|
|
402
|
+
if (!els.length) return undefined;
|
|
403
|
+
return els.map(o => {
|
|
404
|
+
const text = o.textContent?.trim() ?? '';
|
|
405
|
+
const value = o.getAttribute('value');
|
|
406
|
+
return value !== null && text ? {value, label: text} : {value: value ?? text};
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private static _filterFieldFromEl(el: Element): FlowFilterField | null {
|
|
411
|
+
const id = el.getAttribute('id');
|
|
412
|
+
if (!id) return null;
|
|
413
|
+
const value = el.getAttribute('value');
|
|
414
|
+
const type = el.getAttribute('type') as FlowFilterField['type'] | null;
|
|
415
|
+
return {
|
|
416
|
+
id,
|
|
417
|
+
label: el.getAttribute('label') ?? undefined,
|
|
418
|
+
type: type ?? undefined,
|
|
419
|
+
operators: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-operator')
|
|
420
|
+
?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('operators')),
|
|
421
|
+
options: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-option')
|
|
422
|
+
?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('options')),
|
|
423
|
+
units: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-unit')
|
|
424
|
+
?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('units')),
|
|
425
|
+
suffix: el.getAttribute('suffix') ?? undefined,
|
|
426
|
+
placeholder: el.getAttribute('placeholder') ?? undefined,
|
|
427
|
+
value: value === null ? undefined : type === 'number' ? Number(value) : value,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A step's branch filters: the `branch-filters` JSON attribute, or nested
|
|
433
|
+
* `<zn-flow-filter>` declarations each holding `<zn-flow-filter-field>`s.
|
|
434
|
+
*/
|
|
435
|
+
private static _parseBranchFilters(el: Element): FlowBranchFilter[] | undefined {
|
|
436
|
+
// Option lists in the JSON may use the string shorthand — normalise to objects.
|
|
437
|
+
const norm = (opts?: (string | FlowFilterOption)[]) =>
|
|
438
|
+
opts?.map(o => (typeof o === 'string' ? {value: o} : o));
|
|
439
|
+
const attr = el.getAttribute('branch-filters');
|
|
440
|
+
if (attr) {
|
|
441
|
+
try {
|
|
442
|
+
const parsed = JSON.parse(attr) as unknown;
|
|
443
|
+
if (Array.isArray(parsed)) {
|
|
444
|
+
return (parsed as FlowBranchFilter[]).map(f => ({
|
|
445
|
+
...f,
|
|
446
|
+
fields: (f.fields ?? []).map(field => ({
|
|
447
|
+
...field,
|
|
448
|
+
...(field.operators ? {operators: norm(field.operators)} : {}),
|
|
449
|
+
...(field.options ? {options: norm(field.options)} : {}),
|
|
450
|
+
...(field.units ? {units: norm(field.units)} : {}),
|
|
451
|
+
})),
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
/* fall through to nested declarations */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const filters = Array.from(el.querySelectorAll(':scope > zn-flow-filter'))
|
|
459
|
+
.map((f): FlowBranchFilter | null => {
|
|
460
|
+
const id = f.getAttribute('id') ?? f.getAttribute('label');
|
|
461
|
+
if (!id) return null;
|
|
462
|
+
return {
|
|
463
|
+
id,
|
|
464
|
+
label: f.getAttribute('label') ?? id,
|
|
465
|
+
description: f.getAttribute('description') ?? undefined,
|
|
466
|
+
fields: Array.from(f.querySelectorAll(':scope > zn-flow-filter-field'))
|
|
467
|
+
.map(field => ZnFlowBuilder._filterFieldFromEl(field))
|
|
468
|
+
.filter((field): field is FlowFilterField => !!field),
|
|
469
|
+
};
|
|
470
|
+
})
|
|
471
|
+
.filter((f): f is FlowBranchFilter => !!f);
|
|
472
|
+
return filters.length ? filters : undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
217
475
|
private _typeFromStep(el: Element): FlowNodeType | null {
|
|
218
476
|
const type = el.getAttribute('type');
|
|
219
477
|
if (!type) return null;
|
|
478
|
+
// The label falls back to the step's own text only — not the text of
|
|
479
|
+
// nested <zn-flow-filter> declarations.
|
|
480
|
+
const ownText = Array.from(el.childNodes)
|
|
481
|
+
.filter(n => n.nodeType === Node.TEXT_NODE)
|
|
482
|
+
.map(n => n.textContent ?? '')
|
|
483
|
+
.join('')
|
|
484
|
+
.trim();
|
|
220
485
|
return {
|
|
221
486
|
type,
|
|
222
|
-
label: el.getAttribute('label') ??
|
|
487
|
+
label: el.getAttribute('label') ?? (ownText || type),
|
|
223
488
|
group: (el.getAttribute('group') as FlowGroup) ?? 'action',
|
|
224
489
|
category: el.getAttribute('category') ?? undefined,
|
|
225
490
|
icon: el.getAttribute('icon') ?? undefined,
|
|
@@ -228,6 +493,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
228
493
|
description: el.getAttribute('description') ?? undefined,
|
|
229
494
|
inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
|
|
230
495
|
outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
|
|
496
|
+
branchFilters: ZnFlowBuilder._parseBranchFilters(el),
|
|
231
497
|
};
|
|
232
498
|
}
|
|
233
499
|
|
|
@@ -269,6 +535,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
269
535
|
this._redo = [];
|
|
270
536
|
this._selectedNodeId = null;
|
|
271
537
|
this._configRevision++;
|
|
538
|
+
this._offerRestoreIfNewer();
|
|
272
539
|
}
|
|
273
540
|
|
|
274
541
|
get value(): string {
|
|
@@ -288,6 +555,15 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
288
555
|
}
|
|
289
556
|
}
|
|
290
557
|
|
|
558
|
+
/**
|
|
559
|
+
* The full flow state — nodes with their positions, connections, branch
|
|
560
|
+
* data, and notes — ready for persisting. Lets `JSON.stringify(builder)`
|
|
561
|
+
* serialize the flow directly, e.g. as a POST body.
|
|
562
|
+
*/
|
|
563
|
+
toJSON(): FlowState {
|
|
564
|
+
return this.getState();
|
|
565
|
+
}
|
|
566
|
+
|
|
291
567
|
undo = () => {
|
|
292
568
|
const prev = this._history.pop();
|
|
293
569
|
if (!prev) return;
|
|
@@ -396,6 +672,8 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
396
672
|
connections: [...this._state.connections],
|
|
397
673
|
notes: [...this._state.notes],
|
|
398
674
|
};
|
|
675
|
+
// Editing is choosing the loaded flow — stop offering the auto-saved draft.
|
|
676
|
+
this._restorePrompt = null;
|
|
399
677
|
this.emit('zn-flow-change', {detail: {state: this.getState()}});
|
|
400
678
|
};
|
|
401
679
|
|
|
@@ -444,11 +722,27 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
444
722
|
* occupies the spot, walk outward in grid-step rings to the nearest free one.
|
|
445
723
|
*/
|
|
446
724
|
private _freePosition(x: number, y: number, excludeId?: string): { x: number; y: number } {
|
|
447
|
-
const
|
|
448
|
-
this._state.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
725
|
+
const parents = new Set(
|
|
726
|
+
this._state.connections
|
|
727
|
+
.filter(c => excludeId && c.target.node === excludeId && c.source.node !== excludeId)
|
|
728
|
+
.map(c => c.source.node)
|
|
729
|
+
);
|
|
730
|
+
const collides = (px: number, py: number) => {
|
|
731
|
+
// Test with the excluded node AT the candidate spot — its parents' pills
|
|
732
|
+
// centre between the two, so they shift along with the move and must
|
|
733
|
+
// land clear of everything as well.
|
|
734
|
+
const nodes = excludeId
|
|
735
|
+
? this._state.nodes.map(n => (n.id === excludeId ? {...n, x: px, y: py} : n))
|
|
736
|
+
: this._state.nodes;
|
|
737
|
+
const typeOf = (t: string) => this.registry.get(t);
|
|
738
|
+
if (nodes.some(n => n.id !== excludeId && cardCollides({x: px, y: py}, n, typeOf, nodes, this._state.connections))) {
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
return [...parents].some(pid => {
|
|
742
|
+
const parent = nodes.find(n => n.id === pid);
|
|
743
|
+
return !!parent && pillsCollide(parent, typeOf, nodes, this._state.connections);
|
|
744
|
+
});
|
|
745
|
+
};
|
|
452
746
|
x = snapToGrid(x);
|
|
453
747
|
y = snapToGrid(y);
|
|
454
748
|
if (!collides(x, y)) return {x, y};
|
|
@@ -513,11 +807,11 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
513
807
|
private _positionBelowOutput(source: FlowNodeInstance, port: string): { x: number; y: number } {
|
|
514
808
|
const outputs = nodeOutputs(source, this.registry.get(source.type));
|
|
515
809
|
const idx = Math.max(outputs.findIndex(o => o.id === port), 0);
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
// the branch pill, and leaves wire room, so the child lands in a straight
|
|
810
|
+
// Centred under the branch drop (where the pill hangs), a full layer below
|
|
811
|
+
// the source (same rhythm as untangle) — the child lands in a straight
|
|
519
812
|
// line under the branch instead of being shoved sideways by collision.
|
|
520
|
-
|
|
813
|
+
const x = branchDropXs(source, t => this.registry.get(t))[idx];
|
|
814
|
+
return {x: Math.round(x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
|
|
521
815
|
}
|
|
522
816
|
|
|
523
817
|
/**
|
|
@@ -659,6 +953,8 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
659
953
|
|
|
660
954
|
private _select(id: string | null) {
|
|
661
955
|
this._selectedBranch = null;
|
|
956
|
+
// A selection needs the inspector — bring the panel back if it's tucked away.
|
|
957
|
+
if (id) this._sideCollapsed = false;
|
|
662
958
|
if (this._selectedNodeId === id) return;
|
|
663
959
|
this._selectedNodeId = id;
|
|
664
960
|
this.emit('zn-flow-selection-change', {detail: {nodeId: id}});
|
|
@@ -666,6 +962,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
666
962
|
|
|
667
963
|
private _onBranchPick = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
668
964
|
this._select(null);
|
|
965
|
+
this._sideCollapsed = false;
|
|
669
966
|
this._selectedBranch = {nodeId: e.detail.nodeId, port: e.detail.port};
|
|
670
967
|
};
|
|
671
968
|
|
|
@@ -931,8 +1228,9 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
931
1228
|
};
|
|
932
1229
|
// The guard keeps the consumer's config DOM in place across value-only
|
|
933
1230
|
// re-renders (so live-typing inputs keep focus); it rebuilds when the node
|
|
934
|
-
// changes, the state is replaced, or a branch is added / removed
|
|
935
|
-
|
|
1231
|
+
// changes, the state is replaced, or a branch is added / removed /
|
|
1232
|
+
// replaced (ids, not count — a swap like delete-last keeps the count).
|
|
1233
|
+
const configKey = [node.id, this._configRevision, nodeOutputs(node, type).map(p => p.id).join('|')];
|
|
936
1234
|
|
|
937
1235
|
return html`
|
|
938
1236
|
<aside part="inspector" class="inspector">
|
|
@@ -978,6 +1276,13 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
978
1276
|
this._commit();
|
|
979
1277
|
}
|
|
980
1278
|
|
|
1279
|
+
/** Persist the built-in conditions editor's draft onto the branch and close it. Undoable. */
|
|
1280
|
+
private _saveBranchConditions(node: FlowNodeInstance, port: FlowPort, conditions: FlowBranchConditions) {
|
|
1281
|
+
this._pushHistory();
|
|
1282
|
+
this._updateBranch(node, port.id, {data: {...port.data, conditions}});
|
|
1283
|
+
this._selectedBranch = null;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
981
1286
|
// The branch editor: rename an output branch and configure its conditions.
|
|
982
1287
|
private _renderBranchEditor(node: FlowNodeInstance, port: FlowPort) {
|
|
983
1288
|
const type = this.registry.get(node.type);
|
|
@@ -1031,7 +1336,16 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1031
1336
|
></zn-input>
|
|
1032
1337
|
${renderBranchConfig
|
|
1033
1338
|
? guard(configKey, () => renderBranchConfig(node, port, update))
|
|
1034
|
-
:
|
|
1339
|
+
: type?.branchFilters?.length
|
|
1340
|
+
? html`
|
|
1341
|
+
<zn-flow-branch-conditions
|
|
1342
|
+
.filters="${type.branchFilters}"
|
|
1343
|
+
.value="${branchConditions(port)}"
|
|
1344
|
+
@flow-conditions-save="${(e: CustomEvent<{ conditions: FlowBranchConditions }>) =>
|
|
1345
|
+
this._saveBranchConditions(node, port, e.detail.conditions)}"
|
|
1346
|
+
@flow-conditions-cancel="${() => (this._selectedBranch = null)}"
|
|
1347
|
+
></zn-flow-branch-conditions>`
|
|
1348
|
+
: html`<p class="inspector-hint">This step type has no branch conditions.</p>`}
|
|
1035
1349
|
</div>
|
|
1036
1350
|
</aside>
|
|
1037
1351
|
`;
|
|
@@ -1092,6 +1406,34 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1092
1406
|
`;
|
|
1093
1407
|
}
|
|
1094
1408
|
|
|
1409
|
+
// Bottom-left of the canvas: flashes as each auto-save lands, otherwise
|
|
1410
|
+
// shows how long ago the last one happened.
|
|
1411
|
+
private _renderAutoSaveStatus() {
|
|
1412
|
+
if (this.autoSave === null || (!this._justSaved && this._lastSavedAt === null)) return '';
|
|
1413
|
+
const label = this._justSaved
|
|
1414
|
+
? 'Auto-saved'
|
|
1415
|
+
: `Last saved ${timeAgo(Math.max(0, this._statusNow - (this._lastSavedAt ?? 0)))}`;
|
|
1416
|
+
return html`
|
|
1417
|
+
<div class="save-status ${this._justSaved ? 'save-status--saved' : ''}">
|
|
1418
|
+
<zn-icon src="${this._justSaved ? 'check@lu' : 'history@lu'}" size="14"></zn-icon>
|
|
1419
|
+
<span>${label}</span>
|
|
1420
|
+
</div>
|
|
1421
|
+
`;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Offered when a loaded flow differs from a fresh auto-saved draft.
|
|
1425
|
+
private _renderRestorePrompt() {
|
|
1426
|
+
if (!this._restorePrompt) return '';
|
|
1427
|
+
return html`
|
|
1428
|
+
<div class="restore-banner">
|
|
1429
|
+
<zn-icon src="history@lu" size="16"></zn-icon>
|
|
1430
|
+
<span>An auto-saved draft from ${timeAgo(Date.now() - this._restorePrompt.savedAt)} differs from this flow.</span>
|
|
1431
|
+
<button class="restore-banner__restore" @click="${() => this.restoreAutoSave()}">Restore</button>
|
|
1432
|
+
<button class="restore-banner__dismiss" @click="${() => (this._restorePrompt = null)}">Dismiss</button>
|
|
1433
|
+
</div>
|
|
1434
|
+
`;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1095
1437
|
private _renderPicker() {
|
|
1096
1438
|
if (!this._picker) return '';
|
|
1097
1439
|
const {x, y} = this._picker;
|
|
@@ -1134,7 +1476,10 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1134
1476
|
|
|
1135
1477
|
render() {
|
|
1136
1478
|
return html`
|
|
1137
|
-
<div
|
|
1479
|
+
<div
|
|
1480
|
+
part="base"
|
|
1481
|
+
class="builder ${this._stepsCollapsed ? 'builder--steps-collapsed' : ''} ${this._sideCollapsed ? 'builder--side-collapsed' : ''}"
|
|
1482
|
+
>
|
|
1138
1483
|
${this._renderHeader()}
|
|
1139
1484
|
${this._renderSteps()}
|
|
1140
1485
|
<div
|
|
@@ -1142,6 +1487,20 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1142
1487
|
@dragover="${this._onCanvasDragOver}"
|
|
1143
1488
|
@drop="${this._onCanvasDrop}"
|
|
1144
1489
|
>
|
|
1490
|
+
<button
|
|
1491
|
+
class="panel-toggle panel-toggle--left ${this._stepsCollapsed ? 'panel-toggle--tucked' : ''}"
|
|
1492
|
+
title="${this._stepsCollapsed ? 'Show steps panel' : 'Hide steps panel'}"
|
|
1493
|
+
@click="${() => (this._stepsCollapsed = !this._stepsCollapsed)}"
|
|
1494
|
+
>
|
|
1495
|
+
<zn-icon src="${this._stepsCollapsed ? 'chevron-right@lu' : 'chevron-left@lu'}" size="16"></zn-icon>
|
|
1496
|
+
</button>
|
|
1497
|
+
<button
|
|
1498
|
+
class="panel-toggle panel-toggle--right ${this._sideCollapsed ? 'panel-toggle--tucked' : ''}"
|
|
1499
|
+
title="${this._sideCollapsed ? 'Show panel' : 'Hide panel'}"
|
|
1500
|
+
@click="${() => (this._sideCollapsed = !this._sideCollapsed)}"
|
|
1501
|
+
>
|
|
1502
|
+
<zn-icon src="${this._sideCollapsed ? 'chevron-left@lu' : 'chevron-right@lu'}" size="16"></zn-icon>
|
|
1503
|
+
</button>
|
|
1145
1504
|
<zn-flow-canvas
|
|
1146
1505
|
.nodes="${this._state.nodes}"
|
|
1147
1506
|
.connections="${this._state.connections}"
|
|
@@ -1162,6 +1521,8 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1162
1521
|
</div>
|
|
1163
1522
|
`
|
|
1164
1523
|
: ''}
|
|
1524
|
+
${this._renderAutoSaveStatus()}
|
|
1525
|
+
${this._renderRestorePrompt()}
|
|
1165
1526
|
${this._renderPicker()}
|
|
1166
1527
|
</div>
|
|
1167
1528
|
${this._renderRightPanel()}
|