@kubex/zinc 1.1.22 → 1.1.24
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 -4
- package/dist/custom-elements.json +1172 -78
- package/dist/vscode.html-custom-data.json +34 -13
- package/dist/web-types.json +135 -26
- package/dist/zn.d.ts +867 -1
- package/dist/zn.min.js +893 -481
- package/docs/_includes/default.njk +1 -0
- package/docs/_includes/full-page.njk +38 -0
- package/docs/pages/components/flow-builder-demo.njk +194 -0
- package/docs/pages/components/flow-builder-troubleshooter-demo.njk +282 -0
- package/docs/pages/components/flow-builder.md +377 -0
- package/package.json +1 -1
- package/src/components/button/button.component.ts +1 -2
- package/src/components/button/button.scss +6 -16
- package/src/components/collapsible/collapsible.component.ts +11 -6
- package/src/components/data-table/data-table.component.ts +12 -12
- package/src/components/flow-builder/flow-builder.component.ts +1171 -0
- package/src/components/flow-builder/flow-builder.scss +489 -0
- package/src/components/flow-builder/flow-builder.test.ts +59 -0
- package/src/components/flow-builder/flow-layout.ts +139 -0
- package/src/components/flow-builder/flow-registry.ts +52 -0
- package/src/components/flow-builder/flow.types.ts +407 -0
- package/src/components/flow-builder/index.ts +14 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +1086 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +371 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.test.ts +39 -0
- package/src/components/flow-builder/modules/flow-canvas/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +174 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.scss +180 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.test.ts +50 -0
- package/src/components/flow-builder/modules/flow-node/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +88 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.scss +52 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.test.ts +21 -0
- package/src/components/flow-builder/modules/flow-step/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.component.ts +35 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.scss +28 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.test.ts +20 -0
- package/src/components/flow-builder/modules/flow-step-group/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.component.ts +88 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.scss +24 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.test.ts +17 -0
- package/src/components/flow-builder/modules/flow-steps/index.ts +12 -0
- package/src/events/events.ts +3 -0
- package/src/events/zn-flow-change.ts +9 -0
- package/src/events/zn-flow-connect.ts +9 -0
- package/src/events/zn-flow-selection-change.ts +7 -0
- package/src/zinc.ts +4 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
@use "../../wc";
|
|
2
|
+
|
|
3
|
+
:host {
|
|
4
|
+
@include wc.scrollbars; // styles scrollbars of all descendant scroll areas (steps panel, inspector, sidebar, picker)
|
|
5
|
+
display: block;
|
|
6
|
+
width: 100%;
|
|
7
|
+
height: 100%;
|
|
8
|
+
min-height: 480px;
|
|
9
|
+
color: rgb(var(--zn-text));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.builder {
|
|
13
|
+
display: grid;
|
|
14
|
+
grid-template-columns: 343px minmax(0, 1fr) 343px;
|
|
15
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
16
|
+
width: 100%;
|
|
17
|
+
height: 100%;
|
|
18
|
+
background: rgb(var(--zn-panel));
|
|
19
|
+
border: 1px solid rgb(var(--zn-border-color));
|
|
20
|
+
border-radius: 8px;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Full-width action bar above the three panels; hidden unless actions are slotted.
|
|
25
|
+
.header {
|
|
26
|
+
grid-row: 1;
|
|
27
|
+
grid-column: 1 / -1;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: space-between;
|
|
31
|
+
gap: 8px;
|
|
32
|
+
padding: 10px 14px;
|
|
33
|
+
border-bottom: 1px solid rgb(var(--zn-border-color));
|
|
34
|
+
background: rgb(var(--zn-panel));
|
|
35
|
+
|
|
36
|
+
&[hidden] {
|
|
37
|
+
display: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&__group {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 8px;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The panels sit on the second grid row explicitly — the hidden header leaves
|
|
48
|
+
// the grid entirely (display: none), so auto-placement would pull them into the
|
|
49
|
+
// `auto` first row and collapse the builder to its content height.
|
|
50
|
+
.steps,
|
|
51
|
+
.inspector,
|
|
52
|
+
.sidebar {
|
|
53
|
+
grid-row: 2;
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
min-height: 0;
|
|
57
|
+
background: rgb(var(--zn-panel));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Colour the slotted zn-navbar's active tab to match the steps panel surface,
|
|
61
|
+
// rather than the navbar's default body colour.
|
|
62
|
+
.steps {
|
|
63
|
+
--zn-active-nav-background: var(--zn-panel);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Left column (steps panel).
|
|
67
|
+
.steps {
|
|
68
|
+
border-right: 1px solid rgb(var(--zn-border-color));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Right column (version history / errors, or the node/branch editor).
|
|
72
|
+
.sidebar,
|
|
73
|
+
.inspector {
|
|
74
|
+
border-left: 1px solid rgb(var(--zn-border-color));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The editor slides in from the right when a node or branch is selected.
|
|
78
|
+
.inspector {
|
|
79
|
+
animation: inspector-in 0.18s ease;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@keyframes inspector-in {
|
|
83
|
+
from {
|
|
84
|
+
transform: translateX(32px);
|
|
85
|
+
opacity: 0.3;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
to {
|
|
89
|
+
transform: translateX(0);
|
|
90
|
+
opacity: 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.canvas-cell {
|
|
95
|
+
grid-row: 2;
|
|
96
|
+
position: relative;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
min-height: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Steps panel -----------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
// The registry-driven steps panel fills the column below the title block.
|
|
104
|
+
.steps-content {
|
|
105
|
+
flex: 1;
|
|
106
|
+
min-height: 0;
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Slotted <zn-flow-step>s are type declarations only — never displayed.
|
|
112
|
+
.declarations {
|
|
113
|
+
display: none;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.title-block {
|
|
117
|
+
padding: 16px;
|
|
118
|
+
border-bottom: 1px solid rgb(var(--zn-border-color));
|
|
119
|
+
|
|
120
|
+
.heading {
|
|
121
|
+
font-size: 0.9375rem;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.subheading {
|
|
126
|
+
margin-top: 2px;
|
|
127
|
+
font-size: 0.75rem;
|
|
128
|
+
color: rgb(var(--zn-color-muted-text));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.search {
|
|
133
|
+
padding: 12px 12px 8px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Each tab panel fills the zn-tabs content area and lets its category list scroll.
|
|
137
|
+
.steps-panel {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
min-height: 0;
|
|
141
|
+
height: 100%;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Per-tab hint, shown beneath the tab bar with a divider before the categories.
|
|
145
|
+
.steps-hint {
|
|
146
|
+
margin: 0;
|
|
147
|
+
padding: 12px var(--zn-base-gap);
|
|
148
|
+
font-size: 0.8125rem;
|
|
149
|
+
line-height: 1.4;
|
|
150
|
+
color: rgb(var(--zn-color-muted-text));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.steps-scroll {
|
|
154
|
+
@include wc.scrollbars;
|
|
155
|
+
flex: 1;
|
|
156
|
+
min-height: 0;
|
|
157
|
+
overflow-y: auto;
|
|
158
|
+
padding-bottom: 16px;
|
|
159
|
+
|
|
160
|
+
// Divider above every category (and between the hint/tabs and the first one).
|
|
161
|
+
zn-flow-step-group {
|
|
162
|
+
display: block;
|
|
163
|
+
border-top: 1px solid rgb(var(--zn-border-color));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.steps-uncategorized {
|
|
168
|
+
padding: 8px var(--zn-base-gap);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.steps-empty {
|
|
172
|
+
padding: 16px var(--zn-base-gap);
|
|
173
|
+
font-size: 0.8125rem;
|
|
174
|
+
color: rgb(var(--zn-color-muted-text));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.step {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: 10px;
|
|
181
|
+
padding: 8px 10px;
|
|
182
|
+
margin-bottom: 6px;
|
|
183
|
+
background: rgb(var(--zn-panel));
|
|
184
|
+
border: 1px solid rgb(var(--zn-border-color));
|
|
185
|
+
border-radius: 8px;
|
|
186
|
+
font-size: 0.8125rem;
|
|
187
|
+
cursor: grab;
|
|
188
|
+
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
189
|
+
|
|
190
|
+
&:hover {
|
|
191
|
+
border-color: rgb(var(--zn-color-border-active));
|
|
192
|
+
box-shadow: 0 1px 3px rgba(var(--zn-shadow), 0.4);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
&:active {
|
|
196
|
+
cursor: grabbing;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&__icon {
|
|
200
|
+
--node-accent: rgb(var(--zn-color-primary));
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
flex: 0 0 auto;
|
|
205
|
+
width: 28px;
|
|
206
|
+
height: 28px;
|
|
207
|
+
border-radius: 6px;
|
|
208
|
+
color: var(--node-accent);
|
|
209
|
+
background: color-mix(in srgb, var(--node-accent) 14%, transparent);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
&__label {
|
|
213
|
+
min-width: 0;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
text-overflow: ellipsis;
|
|
216
|
+
white-space: nowrap;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Inspector ---------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
.inspector-empty {
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
align-items: center;
|
|
226
|
+
justify-content: center;
|
|
227
|
+
gap: 10px;
|
|
228
|
+
height: 100%;
|
|
229
|
+
padding: 24px;
|
|
230
|
+
text-align: center;
|
|
231
|
+
color: rgb(var(--zn-color-muted-text));
|
|
232
|
+
font-size: 0.8125rem;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.inspector-head {
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
gap: 12px;
|
|
239
|
+
padding: 16px;
|
|
240
|
+
border-bottom: 1px solid rgb(var(--zn-border-color));
|
|
241
|
+
|
|
242
|
+
&__icon {
|
|
243
|
+
--node-accent: rgb(var(--zn-color-primary));
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
flex: 0 0 auto;
|
|
248
|
+
width: 36px;
|
|
249
|
+
height: 36px;
|
|
250
|
+
border-radius: 8px;
|
|
251
|
+
color: var(--node-accent);
|
|
252
|
+
background: color-mix(in srgb, var(--node-accent) 14%, transparent);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Long labels truncate rather than pushing the close button out of the panel.
|
|
256
|
+
&__text {
|
|
257
|
+
flex: 1;
|
|
258
|
+
min-width: 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Title + badges (e.g. the Loop tag) on one line: the title shrinks with an
|
|
262
|
+
// ellipsis while the badge always stays visible.
|
|
263
|
+
&__title-row {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: 6px;
|
|
267
|
+
min-width: 0;
|
|
268
|
+
|
|
269
|
+
.inspector-head__title {
|
|
270
|
+
min-width: 0;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
&__title {
|
|
275
|
+
font-size: 0.875rem;
|
|
276
|
+
font-weight: 600;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
&__title,
|
|
280
|
+
&__type {
|
|
281
|
+
overflow: hidden;
|
|
282
|
+
text-overflow: ellipsis;
|
|
283
|
+
white-space: nowrap;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
&__type {
|
|
287
|
+
font-size: 0.75rem;
|
|
288
|
+
color: rgb(var(--zn-color-muted-text));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.inspector-body {
|
|
293
|
+
@include wc.scrollbars;
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
gap: 14px;
|
|
297
|
+
flex: 1;
|
|
298
|
+
min-height: 0;
|
|
299
|
+
overflow-y: auto;
|
|
300
|
+
padding: 16px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.inspector-hint {
|
|
304
|
+
margin: 0;
|
|
305
|
+
font-size: 0.75rem;
|
|
306
|
+
color: rgb(var(--zn-color-muted-text));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Marks a loop branch in the editor head, matching its amber pill and wire.
|
|
310
|
+
.inspector-loop-tag {
|
|
311
|
+
display: inline-flex;
|
|
312
|
+
align-items: center;
|
|
313
|
+
flex: 0 0 auto;
|
|
314
|
+
gap: 3px;
|
|
315
|
+
padding: 2px 8px;
|
|
316
|
+
border-radius: 999px;
|
|
317
|
+
font-size: 0.6875rem;
|
|
318
|
+
font-weight: 600;
|
|
319
|
+
line-height: 1;
|
|
320
|
+
color: rgb(var(--zn-color-warning));
|
|
321
|
+
background: color-mix(in srgb, rgb(var(--zn-color-warning)) 14%, transparent);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.inspector-close {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
justify-content: center;
|
|
328
|
+
flex: 0 0 auto;
|
|
329
|
+
margin-left: auto;
|
|
330
|
+
width: 28px;
|
|
331
|
+
height: 28px;
|
|
332
|
+
padding: 0;
|
|
333
|
+
border-radius: 6px;
|
|
334
|
+
color: rgb(var(--zn-color-muted-text));
|
|
335
|
+
background: transparent;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
|
|
338
|
+
&:hover {
|
|
339
|
+
background: rgb(var(--zn-color-tab));
|
|
340
|
+
color: rgb(var(--zn-text));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- Sidebar (right rail) ----------------------------------------------------
|
|
345
|
+
|
|
346
|
+
.sidebar {
|
|
347
|
+
@include wc.scrollbars;
|
|
348
|
+
overflow-y: auto;
|
|
349
|
+
padding: 16px;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.sidebar-section__head {
|
|
353
|
+
display: flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
justify-content: space-between;
|
|
356
|
+
margin-bottom: 10px;
|
|
357
|
+
font-size: 0.8125rem;
|
|
358
|
+
font-weight: 600;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.sidebar-count {
|
|
362
|
+
min-width: 20px;
|
|
363
|
+
padding: 0 6px;
|
|
364
|
+
font-size: 0.75rem;
|
|
365
|
+
line-height: 18px;
|
|
366
|
+
text-align: center;
|
|
367
|
+
border-radius: 9px;
|
|
368
|
+
color: rgb(var(--zn-color-muted-text));
|
|
369
|
+
background: rgb(var(--zn-color-tab));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.sidebar-empty {
|
|
373
|
+
margin: 0;
|
|
374
|
+
font-size: 0.75rem;
|
|
375
|
+
color: rgb(var(--zn-color-muted-text));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.sidebar-error {
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
gap: 8px;
|
|
382
|
+
width: 100%;
|
|
383
|
+
margin-bottom: 6px;
|
|
384
|
+
padding: 8px 10px;
|
|
385
|
+
font-size: 0.8125rem;
|
|
386
|
+
text-align: left;
|
|
387
|
+
color: rgb(var(--zn-color-error));
|
|
388
|
+
background: color-mix(in srgb, rgb(var(--zn-color-error)) 7%, transparent);
|
|
389
|
+
border: 1px solid color-mix(in srgb, rgb(var(--zn-color-error)) 25%, transparent);
|
|
390
|
+
border-radius: 8px;
|
|
391
|
+
cursor: pointer;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- "+" picker popover ------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
.picker-backdrop {
|
|
397
|
+
position: fixed;
|
|
398
|
+
inset: 0;
|
|
399
|
+
z-index: 20;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.picker {
|
|
403
|
+
position: fixed;
|
|
404
|
+
z-index: 21;
|
|
405
|
+
width: 220px;
|
|
406
|
+
max-height: 320px;
|
|
407
|
+
overflow-y: auto;
|
|
408
|
+
padding: 6px;
|
|
409
|
+
background: rgb(var(--zn-panel));
|
|
410
|
+
border: 1px solid rgb(var(--zn-border-color));
|
|
411
|
+
border-radius: 8px;
|
|
412
|
+
box-shadow: 0 6px 24px rgba(var(--zn-shadow), 0.5);
|
|
413
|
+
@include wc.scrollbars;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.picker-group {
|
|
417
|
+
padding: 8px 8px 4px;
|
|
418
|
+
font-size: 0.6875rem;
|
|
419
|
+
font-weight: 600;
|
|
420
|
+
text-transform: uppercase;
|
|
421
|
+
letter-spacing: 0.04em;
|
|
422
|
+
color: rgb(var(--zn-color-muted-text));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.picker-item {
|
|
426
|
+
display: flex;
|
|
427
|
+
align-items: center;
|
|
428
|
+
gap: 10px;
|
|
429
|
+
width: 100%;
|
|
430
|
+
padding: 7px 8px;
|
|
431
|
+
font-size: 0.8125rem;
|
|
432
|
+
text-align: left;
|
|
433
|
+
background: transparent;
|
|
434
|
+
border-radius: 6px;
|
|
435
|
+
cursor: pointer;
|
|
436
|
+
|
|
437
|
+
&:hover {
|
|
438
|
+
background: rgb(var(--zn-color-tab));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
&__icon {
|
|
442
|
+
--node-accent: rgb(var(--zn-color-primary));
|
|
443
|
+
display: flex;
|
|
444
|
+
align-items: center;
|
|
445
|
+
justify-content: center;
|
|
446
|
+
flex: 0 0 auto;
|
|
447
|
+
width: 26px;
|
|
448
|
+
height: 26px;
|
|
449
|
+
border-radius: 6px;
|
|
450
|
+
color: var(--node-accent);
|
|
451
|
+
background: color-mix(in srgb, var(--node-accent) 14%, transparent);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.picker-empty {
|
|
456
|
+
margin: 0;
|
|
457
|
+
padding: 10px;
|
|
458
|
+
font-size: 0.75rem;
|
|
459
|
+
color: rgb(var(--zn-color-muted-text));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- Move banner -------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
.move-banner {
|
|
465
|
+
position: absolute;
|
|
466
|
+
top: 16px;
|
|
467
|
+
left: 50%;
|
|
468
|
+
transform: translateX(-50%);
|
|
469
|
+
z-index: 5;
|
|
470
|
+
display: flex;
|
|
471
|
+
align-items: center;
|
|
472
|
+
gap: 8px;
|
|
473
|
+
padding: 8px 12px;
|
|
474
|
+
font-size: 0.8125rem;
|
|
475
|
+
color: rgb(var(--zn-panel));
|
|
476
|
+
background: rgb(var(--zn-color-primary));
|
|
477
|
+
border-radius: 8px;
|
|
478
|
+
box-shadow: 0 2px 8px rgba(var(--zn-shadow), 0.5);
|
|
479
|
+
|
|
480
|
+
button {
|
|
481
|
+
padding: 2px 8px;
|
|
482
|
+
font-size: 0.75rem;
|
|
483
|
+
font-weight: 600;
|
|
484
|
+
color: rgb(var(--zn-panel));
|
|
485
|
+
background: rgba(255, 255, 255, 0.2);
|
|
486
|
+
border-radius: 6px;
|
|
487
|
+
cursor: pointer;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
+
import type {FlowNodeType, FlowState} from './flow.types';
|
|
4
|
+
import type ZnFlowBuilder from './flow-builder.component';
|
|
5
|
+
|
|
6
|
+
const TRIGGER: FlowNodeType = {type: 'webhook', label: 'Webhook', group: 'trigger', category: 'Contacts'};
|
|
7
|
+
const ACTION: FlowNodeType = {type: 'email', label: 'Send Email', group: 'action'};
|
|
8
|
+
|
|
9
|
+
describe('<zn-flow-builder>', () => {
|
|
10
|
+
it('should render a component', async () => {
|
|
11
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
12
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
13
|
+
expect(el).to.exist;
|
|
14
|
+
expect(el.shadowRoot?.querySelector('zn-flow-canvas')).to.exist;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should list registered node types for the active tab', async () => {
|
|
18
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
19
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
20
|
+
el.registerNodeTypes([TRIGGER, ACTION]);
|
|
21
|
+
await el.updateComplete;
|
|
22
|
+
|
|
23
|
+
// Each group renders its own zn-tabs panel; the default (Triggers) panel is first.
|
|
24
|
+
const triggerPanel = el.shadowRoot?.querySelectorAll('.steps-panel')[0];
|
|
25
|
+
const items = triggerPanel?.querySelectorAll('.step');
|
|
26
|
+
expect(items?.length).to.equal(1); // only the trigger shows on the default Triggers tab
|
|
27
|
+
expect(items?.[0].textContent).to.contain('Webhook');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should round-trip state through getState/setState', async () => {
|
|
31
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
32
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
33
|
+
const state: FlowState = {
|
|
34
|
+
nodes: [{id: 'n1', type: 'webhook', x: 10, y: 20, data: {}}],
|
|
35
|
+
connections: [],
|
|
36
|
+
notes: [],
|
|
37
|
+
};
|
|
38
|
+
el.setState(state);
|
|
39
|
+
await el.updateComplete;
|
|
40
|
+
|
|
41
|
+
expect(el.getState().nodes).to.have.length(1);
|
|
42
|
+
expect(el.getState().nodes[0].id).to.equal('n1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should parse the value attribute as JSON', async () => {
|
|
46
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
47
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
48
|
+
el.value = JSON.stringify({nodes: [{id: 'a', type: 'x', x: 0, y: 0, data: {}}]});
|
|
49
|
+
await el.updateComplete;
|
|
50
|
+
expect(el.getState().nodes).to.have.length(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should treat undo as a no-op with empty history', async () => {
|
|
54
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
55
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
56
|
+
expect(() => el.undo()).to.not.throw();
|
|
57
|
+
expect(el.getState().nodes).to.have.length(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BRANCH_SPREAD,
|
|
3
|
+
type FlowConnection,
|
|
4
|
+
type FlowNodeInstance,
|
|
5
|
+
type FlowNodeType,
|
|
6
|
+
type FlowState,
|
|
7
|
+
loopConnections,
|
|
8
|
+
NODE_WIDTH,
|
|
9
|
+
nodeOutputs,
|
|
10
|
+
snapToGrid,
|
|
11
|
+
} from './flow.types';
|
|
12
|
+
|
|
13
|
+
/** Horizontal gap between node origins within a layer. */
|
|
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;
|
|
17
|
+
const MARGIN = 40;
|
|
18
|
+
|
|
19
|
+
const avg = (ns: number[]) => ns.reduce((a, b) => a + b, 0) / ns.length;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* "Untangle" auto-layout: assigns every node a position in a layered, top-down
|
|
23
|
+
* flow. Layers come from the longest path back to a root (the graph is a DAG —
|
|
24
|
+
* connects are cycle-guarded), ordering within a layer follows where each node's
|
|
25
|
+
* parents sit (barycenter, respecting the parents' output-port order), and the
|
|
26
|
+
* coordinate passes centre children under the branch anchors they hang from.
|
|
27
|
+
* Returns the new positions; the caller applies them.
|
|
28
|
+
*/
|
|
29
|
+
export function untangledPositions(
|
|
30
|
+
state: FlowState,
|
|
31
|
+
typeOf: (type: string) => FlowNodeType | undefined
|
|
32
|
+
): Map<string, { x: number; y: number }> {
|
|
33
|
+
const out = new Map<string, { x: number; y: number }>();
|
|
34
|
+
const nodes = state.nodes;
|
|
35
|
+
if (!nodes.length) return out;
|
|
36
|
+
|
|
37
|
+
const byId = new Map(nodes.map(n => [n.id, n] as const));
|
|
38
|
+
const incoming = new Map<string, FlowConnection[]>(nodes.map(n => [n.id, []]));
|
|
39
|
+
const outgoing = new Map<string, FlowConnection[]>(nodes.map(n => [n.id, []]));
|
|
40
|
+
for (const c of state.connections) {
|
|
41
|
+
if (!byId.has(c.source.node) || !byId.has(c.target.node)) continue;
|
|
42
|
+
incoming.get(c.target.node)!.push(c);
|
|
43
|
+
outgoing.get(c.source.node)!.push(c);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1. Loops are allowed in the flow, but layering needs a DAG: ignore each
|
|
47
|
+
// cycle's back-edge. Longest-path layering then puts roots (and isolated
|
|
48
|
+
// nodes) at 0 and every other node one layer below its deepest parent —
|
|
49
|
+
// loop wires simply travel back up.
|
|
50
|
+
const backEdges = loopConnections(nodes, state.connections);
|
|
51
|
+
|
|
52
|
+
const layerOf = new Map<string, number>();
|
|
53
|
+
const layerFor = (id: string): number => {
|
|
54
|
+
const known = layerOf.get(id);
|
|
55
|
+
if (known !== undefined) return known;
|
|
56
|
+
const ins = incoming.get(id)!.filter(c => !backEdges.has(c));
|
|
57
|
+
const layer = ins.length ? Math.max(...ins.map(c => layerFor(c.source.node))) + 1 : 0;
|
|
58
|
+
layerOf.set(id, layer);
|
|
59
|
+
return layer;
|
|
60
|
+
};
|
|
61
|
+
nodes.forEach(n => layerFor(n.id));
|
|
62
|
+
|
|
63
|
+
const layers: FlowNodeInstance[][] = Array.from(
|
|
64
|
+
{length: Math.max(...layerOf.values()) + 1},
|
|
65
|
+
() => []
|
|
66
|
+
);
|
|
67
|
+
nodes.forEach(n => layers[layerOf.get(n.id)!].push(n));
|
|
68
|
+
|
|
69
|
+
// The canvas-space x offset of a connection's branch anchor from its source
|
|
70
|
+
// node's origin (branches spread symmetrically around the bottom centre).
|
|
71
|
+
const branchOffset = (c: FlowConnection): number => {
|
|
72
|
+
const parent = byId.get(c.source.node)!;
|
|
73
|
+
const outputs = nodeOutputs(parent, typeOf(parent.type));
|
|
74
|
+
const count = Math.max(outputs.length, 1);
|
|
75
|
+
const idx = Math.max(outputs.findIndex(p => p.id === c.source.port), 0);
|
|
76
|
+
return count === 1 ? 0 : (idx - (count - 1) / 2) * BRANCH_SPREAD;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 2. Ordering: roots keep their left-to-right order; deeper layers sort by the
|
|
80
|
+
// mean of their parents' order (nudged by which output port they hang from),
|
|
81
|
+
// which keeps siblings in port order and reduces wire crossings.
|
|
82
|
+
const orderIdx = new Map<string, number>();
|
|
83
|
+
layers[0].sort((a, b) => a.x - b.x).forEach((n, i) => orderIdx.set(n.id, i));
|
|
84
|
+
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));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Coordinates: place a layer at each node's desired x, resolving overlaps
|
|
101
|
+
// left-to-right, then shift the layer back so it stays centred on the wish.
|
|
102
|
+
const xs = new Map<string, number>();
|
|
103
|
+
const place = (layer: FlowNodeInstance[], desired: (n: FlowNodeInstance) => number | null) => {
|
|
104
|
+
const wishes = layer.map(n => desired(n) ?? xs.get(n.id) ?? 0);
|
|
105
|
+
let prev = -Infinity;
|
|
106
|
+
const placed = wishes.map(w => (prev = Math.max(w, prev + LAYOUT_H_GAP)));
|
|
107
|
+
const shift = avg(placed.map((x, i) => x - wishes[i]));
|
|
108
|
+
layer.forEach((n, i) => xs.set(n.id, placed[i] - shift));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const parentWish = (n: FlowNodeInstance): number | null => {
|
|
112
|
+
// Forward parents only, and only ones already placed (a loop's back-edge
|
|
113
|
+
// parent sits in a deeper layer).
|
|
114
|
+
const ins = incoming.get(n.id)!.filter(c => !backEdges.has(c) && xs.has(c.source.node));
|
|
115
|
+
return ins.length ? avg(ins.map(c => xs.get(c.source.node)! + branchOffset(c))) : null;
|
|
116
|
+
};
|
|
117
|
+
const childWish = (n: FlowNodeInstance): number | null => {
|
|
118
|
+
const outs = outgoing.get(n.id)!.filter(c => !backEdges.has(c) && xs.has(c.target.node));
|
|
119
|
+
return outs.length ? avg(outs.map(c => xs.get(c.target.node)! - branchOffset(c))) : null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
layers[0].forEach((n, i) => xs.set(n.id, i * LAYOUT_H_GAP));
|
|
123
|
+
for (let l = 1; l < layers.length; l++) place(layers[l], parentWish); // down
|
|
124
|
+
for (let l = layers.length - 2; l >= 0; l--) place(layers[l], childWish); // up: recentre over children
|
|
125
|
+
for (let l = 1; l < layers.length; l++) place(layers[l], parentWish); // settle
|
|
126
|
+
|
|
127
|
+
// Safety net: never emit a non-finite coordinate — a NaN here would leave
|
|
128
|
+
// nodes unpositioned (stacked at the origin).
|
|
129
|
+
const finite = [...xs.values()].filter(Number.isFinite);
|
|
130
|
+
const minX = finite.length ? Math.min(...finite) : 0;
|
|
131
|
+
nodes.forEach(n => {
|
|
132
|
+
const raw = xs.get(n.id);
|
|
133
|
+
out.set(n.id, {
|
|
134
|
+
x: snapToGrid(Number.isFinite(raw) ? (raw as number) - minX + MARGIN : MARGIN),
|
|
135
|
+
y: MARGIN + layerOf.get(n.id)! * LAYOUT_V_GAP,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return out;
|
|
139
|
+
}
|