@kubex/zinc 1.1.94 → 1.1.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/custom-elements.json +653 -89
- package/dist/vscode.html-custom-data.json +73 -18
- package/dist/web-types.json +145 -35
- package/dist/zn.d.ts +204 -39
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +374 -308
- package/docs/pages/components/flow-builder.md +17 -1
- package/docs/pages/components/page-builder.md +31 -0
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/button/button.scss +5 -2
- package/src/components/flow-builder/flow-builder.component.ts +16 -1
- package/src/components/flow-builder/flow-builder.test.ts +206 -1
- package/src/components/flow-builder/flow-geometry.test.ts +102 -0
- package/src/components/flow-builder/flow.types.ts +82 -15
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +163 -108
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -0
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/page/page.scss +7 -2
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +62 -4
- package/src/components/page-builder/page-builder.test.ts +94 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +227 -12
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +258 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- package/src/components/translations/translations.component.ts +5 -1
|
@@ -11,7 +11,12 @@ The Flow Builder is a three-panel editor for visual automations, with an optiona
|
|
|
11
11
|
- **Header** (optional) — a full-width action bar rendered only when `slot="header-left"` / `slot="header-right"`
|
|
12
12
|
content is provided (e.g. Close / Undo All Changes on the left, Apply Changes on the right).
|
|
13
13
|
- **Left panel** — the steps panel (searchable, tabbed by step group).
|
|
14
|
-
- **Canvas** (center) — a pannable, zoomable surface.
|
|
14
|
+
- **Canvas** (center) — a pannable, zoomable surface. A node's branches fan out along a bus below it,
|
|
15
|
+
spaced by what they actually hold: two branches with children keep a full card lane apart, while a
|
|
16
|
+
branch that is only a pill sits close in, so declaring branches you have not wired costs little room.
|
|
17
|
+
Wires never run through a card: a branch that reaches past the next row keeps its pill on its own bus
|
|
18
|
+
and takes its wire out around the outside of whatever it flies over, so it always reads as ending where
|
|
19
|
+
it ends. Connections are **append-only**: click any **output port**
|
|
15
20
|
on a node to start a **stray branch** — the wire follows your cursor until you click a node (or its output
|
|
16
21
|
port) to attach it. Fan-in is allowed, and so are **loops** — a branch may point back to an earlier step (e.g.
|
|
17
22
|
an answer that restarts the questioning); only wiring a node directly to itself is refused. Cancel by clicking
|
|
@@ -359,6 +364,12 @@ they're declarations that drive the rendered panel):
|
|
|
359
364
|
trigger with no input.
|
|
360
365
|
- `outputs` — a JSON array of ports, each a string id or a `{"id","label"}` object (e.g.
|
|
361
366
|
`outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'`). Omit for a single default output.
|
|
367
|
+
- `fixed-outputs` — the declared outputs are the *only* branches this step has: their pills carry no
|
|
368
|
+
delete, and clicking a fully-wired node's output adds nothing. Use it when the branches mirror fixed
|
|
369
|
+
fields in your own model (a success / failure / skip triple, say), where an extra or missing branch has
|
|
370
|
+
nowhere to be stored. Branches a step declares but has not wired cost almost no room — they are spaced
|
|
371
|
+
by their pill rather than by a whole child lane — so a step can offer every branch it supports without
|
|
372
|
+
fanning empty lanes across the canvas.
|
|
362
373
|
|
|
363
374
|
Set an optional per-tab hint with the `triggers-hint` / `actions-hint` / `rules-hint` attributes on the
|
|
364
375
|
builder. (A node's inspector `renderConfig` can't be expressed in markup — supply it via
|
|
@@ -604,6 +615,11 @@ builder.registerNodeTypes([
|
|
|
604
615
|
A node type with `inputs: []` is a starting point (a trigger) with no incoming port. A type with
|
|
605
616
|
multiple `outputs` renders one labeled port per output, so a branch can fan out to different steps.
|
|
606
617
|
|
|
618
|
+
Add **`fixedOutputs: true`** when those outputs are the only branches the type can have — the canvas
|
|
619
|
+
drops the delete on their pills and refuses to materialise an extra one. Unwired branches are spaced by
|
|
620
|
+
their pill rather than a child lane, so a type can declare its full set of branches up front without the
|
|
621
|
+
canvas fanning empty lanes for the ones a given node is not using.
|
|
622
|
+
|
|
607
623
|
#### User-configurable outputs
|
|
608
624
|
|
|
609
625
|
A type's `outputs` are the default. For steps whose branches are defined by the user (e.g. a conditional
|
|
@@ -145,6 +145,37 @@ through the `config` attribute. Sections appear in page order; container section
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
## A required first section
|
|
149
|
+
|
|
150
|
+
Pages that must always open with a particular section — a hero banner, a masthead — set
|
|
151
|
+
`required-first` to that section type. The builder hoists an existing section of the type to
|
|
152
|
+
the top of the page, or inserts an empty one when there is none, and pins it there: it has no
|
|
153
|
+
remove action, ignores <kbd>Delete</kbd>, can't be dragged or moved into a container slot, and
|
|
154
|
+
nothing can be dropped above it. Its content stays fully editable in the inspector, and the
|
|
155
|
+
type stays in the palette, so further sections of it can still be added below.
|
|
156
|
+
|
|
157
|
+
Which section is pinned is derived from the state — `sections[0]` when its type matches — so
|
|
158
|
+
nothing about the lock is written into the persisted config.
|
|
159
|
+
|
|
160
|
+
```html:preview
|
|
161
|
+
<zn-page-builder heading="KB Homepage" required-first="hero" style="height: 420px"
|
|
162
|
+
config='{"sections":[{"id":"t1","type":"rich-text","data":{"content":"Welcome"}}]}'>
|
|
163
|
+
<template type="hero" slot="config" label="Hero" icon="star" category="Headers"
|
|
164
|
+
description="Banner with a heading and optional search">
|
|
165
|
+
<zn-input name="title" label="Title"></zn-input>
|
|
166
|
+
<zn-toggle name="showSearch" label="Show search"></zn-toggle>
|
|
167
|
+
</template>
|
|
168
|
+
<template type="rich-text" slot="config" label="Rich Text" icon="notes" category="Content"
|
|
169
|
+
description="A block of markdown content">
|
|
170
|
+
<zn-input name="content" label="Content"></zn-input>
|
|
171
|
+
</template>
|
|
172
|
+
</zn-page-builder>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The config above declares only a rich-text section, so the hero is inserted above it — loading a
|
|
176
|
+
page that lacks the required section normalises it rather than rejecting it. The guard is
|
|
177
|
+
client-side, so a host that persists the config should still enforce the same rule on save.
|
|
178
|
+
|
|
148
179
|
## Container tiles
|
|
149
180
|
|
|
150
181
|
A section type with a `slots` attribute becomes a full-row container: its card renders a
|
|
@@ -16,15 +16,16 @@ for you, as [`zn-textarea`](/components/textarea#slash-menu-quick-insertions) do
|
|
|
16
16
|
slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}"></zn-textarea>
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Slot one into a textarea when you want the panel's own settings — `
|
|
20
|
-
|
|
19
|
+
Slot one into a textarea when you want the panel's own settings — `max-items`, `placement`, `empty-text`, its width, or
|
|
20
|
+
the `heading` the list is announced by — declared in markup. The textarea then drives your menu instead of building its
|
|
21
|
+
own:
|
|
21
22
|
|
|
22
23
|
```html:preview
|
|
23
24
|
<zn-textarea label="Terms and conditions" rows="6" help-text="Type / to insert">
|
|
24
25
|
<zn-slash-menu slot="slash-menu" heading="Replacement strings" max-items="6" style="--slash-menu-width: 360px">
|
|
25
|
-
<zn-slash-item icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
|
|
26
|
-
<zn-slash-item icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
|
|
27
|
-
<zn-slash-item icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
|
|
26
|
+
<zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
|
|
27
|
+
<zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
|
|
28
|
+
<zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
|
|
28
29
|
</zn-slash-menu>
|
|
29
30
|
</zn-textarea>
|
|
30
31
|
```
|
|
@@ -107,9 +108,134 @@ The menu doesn't listen for keys itself — whatever owns the field decides whic
|
|
|
107
108
|
</script>
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
### Grouping Items
|
|
112
|
+
|
|
113
|
+
An item's `group` puts a heading above it. The heading is drawn whenever the group changes going down the list, so
|
|
114
|
+
items sharing a group must be declared together — the menu lists them in the order it is given rather than gathering
|
|
115
|
+
them for you. Leave the group off and an item is listed under no heading at all; declared first, those lead the list,
|
|
116
|
+
which is how a handful of favourites can sit above named sections.
|
|
117
|
+
|
|
118
|
+
```html:preview
|
|
119
|
+
<zn-textarea label="Privacy policy" rows="7" help-text="Type / to see the sections, or 'company' to search across them">
|
|
120
|
+
<zn-slash-menu slot="slash-menu" style="--slash-menu-width: 340px">
|
|
121
|
+
<zn-slash-item icon="star@lu" label="Merchant block" value="{{MERCHANT_BLOCK}}"></zn-slash-item>
|
|
122
|
+
<zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" keywords="company" value="{{BRAND_NAME}}"></zn-slash-item>
|
|
123
|
+
<zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" keywords="company" value="{{LEGAL_ENTITY}}"></zn-slash-item>
|
|
124
|
+
<zn-slash-item group="Customer" icon="user@lu" label="Customer name" value="{{CUSTOMER_NAME}}"></zn-slash-item>
|
|
125
|
+
<zn-slash-item group="Customer" icon="mail@lu" label="Customer email" value="{{CUSTOMER_EMAIL}}"></zn-slash-item>
|
|
126
|
+
<zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
|
|
127
|
+
<zn-slash-item group="Policy" icon="calendar@lu" label="Refund window" value="{{REFUND_DAYS}} days"></zn-slash-item>
|
|
128
|
+
</zn-slash-menu>
|
|
129
|
+
</zn-textarea>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Group headings are only a structure for browsing: a query ranks every match on merit, and the headings follow whatever
|
|
133
|
+
order that leaves. Set `order` on an item to pin its place within a match band, and use `keywords` to make it findable
|
|
134
|
+
by terms that aren't in its label.
|
|
135
|
+
|
|
136
|
+
Driving the menu yourself, the same thing is a `group` on each item:
|
|
137
|
+
|
|
138
|
+
```html:preview
|
|
139
|
+
<zn-button id="grouped-anchor">Open the menu</zn-button>
|
|
140
|
+
<zn-slash-menu id="grouped-menu"></zn-slash-menu>
|
|
141
|
+
<div id="grouped-log" style="margin-top: 1rem; font-family: monospace; font-size: 0.875rem;"></div>
|
|
142
|
+
|
|
143
|
+
<script type="module">
|
|
144
|
+
const anchor = document.getElementById('grouped-anchor');
|
|
145
|
+
const menu = document.getElementById('grouped-menu');
|
|
146
|
+
const log = document.getElementById('grouped-log');
|
|
147
|
+
|
|
148
|
+
await customElements.whenDefined('zn-slash-menu');
|
|
149
|
+
|
|
150
|
+
menu.items = [
|
|
151
|
+
{label: 'Paragraph', value: '', icon: 'type@lu', group: 'Basic blocks'},
|
|
152
|
+
{label: 'Heading 1', value: '# ', icon: 'heading-1@lu', group: 'Basic blocks'},
|
|
153
|
+
{label: 'Heading 2', value: '## ', icon: 'heading-2@lu', group: 'Basic blocks'},
|
|
154
|
+
{label: 'To-do list', value: '- [ ] ', icon: 'square-check@lu', group: 'Lists'},
|
|
155
|
+
{label: 'Bulleted list', value: '- ', icon: 'list@lu', group: 'Lists'},
|
|
156
|
+
{label: 'Callout', value: 'NOTE: ', icon: 'info@lu', group: 'Advanced'},
|
|
157
|
+
{label: 'Quote', value: '> ', icon: 'quote@lu', group: 'Advanced'}
|
|
158
|
+
];
|
|
159
|
+
menu.hideKeys = true;
|
|
160
|
+
menu.anchor = anchor;
|
|
161
|
+
|
|
162
|
+
anchor.addEventListener('click', () => menu.open ? menu.hide() : menu.show());
|
|
163
|
+
|
|
164
|
+
menu.addEventListener('zn-slash-item-select', (event) => {
|
|
165
|
+
log.textContent = `selected ${event.detail.item.label} from ${event.detail.item.group}`;
|
|
166
|
+
menu.hide();
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Keyboard Hints
|
|
172
|
+
|
|
173
|
+
A footer pinned to the bottom of the panel spells out the keys the menu answers to. The list scrolls beneath it, so
|
|
174
|
+
the hints stay in view. Use `hide-hints` on menus driven entirely by the mouse, or where the surrounding UI already
|
|
175
|
+
explains the shortcuts.
|
|
176
|
+
|
|
177
|
+
```html:preview
|
|
178
|
+
<zn-textarea label="Terms and conditions" rows="4" help-text="Type / to see the hints"
|
|
179
|
+
slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}}">
|
|
180
|
+
<zn-slash-menu slot="slash-menu" heading="Replacement strings"></zn-slash-menu>
|
|
181
|
+
</zn-textarea>
|
|
182
|
+
|
|
183
|
+
<zn-textarea label="Internal note" rows="4" help-text="Type / — no hints"
|
|
184
|
+
slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}}">
|
|
185
|
+
<zn-slash-menu slot="slash-menu" heading="Replacement strings" hide-hints></zn-slash-menu>
|
|
186
|
+
</zn-textarea>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Recently Used
|
|
190
|
+
|
|
191
|
+
Set `recent-key` and the menu remembers what was chosen there, listing the most recent of those items above the rest
|
|
192
|
+
under their own heading. The key is where the menu is used — `page-body`, `ticket-reply` — so each place keeps its own
|
|
193
|
+
history in `localStorage`, and two fields that should share one can share a key. `max-recent` caps the section
|
|
194
|
+
(3 by default), `recent-heading` names it, and `clearRecent()` forgets the lot. The section stands aside as soon as
|
|
195
|
+
there is a query, when the ranked matches are the better answer.
|
|
196
|
+
|
|
197
|
+
It reads as one more group, so it sits naturally above [grouped items](#grouping-items) — the section's own heading, then
|
|
198
|
+
the sections the list already had. Insert a few from the first field below to see it fill:
|
|
199
|
+
|
|
200
|
+
```html:preview
|
|
201
|
+
<zn-textarea label="Privacy policy" rows="6" help-text="Type / and insert a few — they come back to the top">
|
|
202
|
+
<zn-slash-menu slot="slash-menu" recent-key="docs-grouped" style="--slash-menu-width: 340px">
|
|
203
|
+
<zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
|
|
204
|
+
<zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
|
|
205
|
+
<zn-slash-item group="Customer" icon="user@lu" label="Customer name" value="{{CUSTOMER_NAME}}"></zn-slash-item>
|
|
206
|
+
<zn-slash-item group="Customer" icon="mail@lu" label="Customer email" value="{{CUSTOMER_EMAIL}}"></zn-slash-item>
|
|
207
|
+
<zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
|
|
208
|
+
</zn-slash-menu>
|
|
209
|
+
</zn-textarea>
|
|
210
|
+
|
|
211
|
+
<zn-button id="forget-recent" style="margin-top: 1rem">Forget them</zn-button>
|
|
212
|
+
|
|
213
|
+
<script type="module">
|
|
214
|
+
import {clearRecentSlashItems} from '/dist/zn.min.js';
|
|
215
|
+
|
|
216
|
+
document.getElementById('forget-recent').addEventListener('click', () => {
|
|
217
|
+
clearRecentSlashItems('docs-grouped');
|
|
218
|
+
clearRecentSlashItems('docs-terms');
|
|
219
|
+
});
|
|
220
|
+
</script>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Where the items below carry no heading of their own, a rule closes the section off instead. This field shares nothing
|
|
224
|
+
with the one above — each key is its own history:
|
|
225
|
+
|
|
226
|
+
```html:preview
|
|
227
|
+
<zn-textarea label="Terms and conditions" rows="5" help-text="Type / and insert a few — the rule marks where they end"
|
|
228
|
+
slash-recent-key="docs-terms"
|
|
229
|
+
slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}},
|
|
230
|
+
Customer name={{CUSTOMER_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`zn-input`, `zn-inline-edit`, `zn-translations` and `zn-remarkd-editor` take the same `slash-recent-key`. On a menu you
|
|
234
|
+
slot in yourself, or drive with `SlashMenuController`, set `recent-key` on the `zn-slash-menu` directly.
|
|
235
|
+
|
|
110
236
|
### Truncating Long Lists
|
|
111
237
|
|
|
112
|
-
`max-items` caps how many items are rendered; the rest are reported in a footer rather than silently dropped. The
|
|
238
|
+
`max-items` caps how many items are rendered; the rest are reported in a footer rather than silently dropped. The list
|
|
113
239
|
scrolls when its content exceeds `--slash-menu-max-height`.
|
|
114
240
|
|
|
115
241
|
```html:preview
|
|
@@ -501,6 +501,22 @@ the merge fields an application allows.
|
|
|
501
501
|
</script>
|
|
502
502
|
```
|
|
503
503
|
|
|
504
|
+
#### Recently Used Items
|
|
505
|
+
|
|
506
|
+
`slash-recent-key` remembers what was inserted here and lists those items above the rest next time, under a
|
|
507
|
+
"Recently used" heading. The key names the place the field is used, so each keeps its own history — see
|
|
508
|
+
[`zn-slash-menu`](/components/slash-menu#recently-used) for the menu's own settings.
|
|
509
|
+
|
|
510
|
+
```html:preview
|
|
511
|
+
<zn-textarea
|
|
512
|
+
label="Refund policy"
|
|
513
|
+
rows="5"
|
|
514
|
+
slash-recent-key="docs-textarea-refunds"
|
|
515
|
+
help-text="Type / and insert a couple — they come back to the top"
|
|
516
|
+
slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}},
|
|
517
|
+
Refund window={{REFUND_DAYS}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>
|
|
518
|
+
```
|
|
519
|
+
|
|
504
520
|
#### Changing the Trigger
|
|
505
521
|
|
|
506
522
|
Set `slash-trigger` to any characters. Using `{{` lets someone who already knows the token they want type it directly
|
package/package.json
CHANGED
package/scss/_root.scss
CHANGED
|
@@ -350,8 +350,14 @@ ul.square {
|
|
|
350
350
|
|
|
351
351
|
// Frosted-glass inactive tabs for the zn-page tab navbar (consumed in
|
|
352
352
|
// components/navbar/navbar.scss, scoped there to :host-context(zn-page)).
|
|
353
|
+
//
|
|
354
|
+
// The translucent background carries the frosted look on its own. Do NOT put a
|
|
355
|
+
// backdrop-filter here: navbar.scss applies it per inactive tab, so each tab
|
|
356
|
+
// becomes its own render surface and re-samples + re-blurs its backdrop every
|
|
357
|
+
// frame. On a page with a normal number of tabs that alone pushed GPU draw to
|
|
358
|
+
// ~8ms/frame and dropped frames on every scroll.
|
|
353
359
|
--navbar-page-inactive-tab-background: rgba(255, 255, 255, 0.5);
|
|
354
|
-
--navbar-page-inactive-tab-filter:
|
|
360
|
+
--navbar-page-inactive-tab-filter: none;
|
|
355
361
|
|
|
356
362
|
.zn-primary {
|
|
357
363
|
color: rgb(var(--zn-color-primary));
|
|
@@ -456,8 +456,11 @@
|
|
|
456
456
|
inset-inline-start: 0;
|
|
457
457
|
bottom: 0;
|
|
458
458
|
border-left: solid 1px;
|
|
459
|
-
|
|
460
|
-
|
|
459
|
+
// PERF: a blend mode here gave every non-first button in every group its own
|
|
460
|
+
// render surface, and the :not(:hover) above meant hovering a group added and
|
|
461
|
+
// removed surfaces — re-running layerize and re-compositing on pointer move.
|
|
462
|
+
// A semi-transparent border reads the same over the button fills.
|
|
463
|
+
color: rgba(var(--zn-border-color), 0.85);
|
|
461
464
|
}
|
|
462
465
|
}
|
|
463
466
|
|
|
@@ -493,6 +493,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
493
493
|
description: el.getAttribute('description') ?? undefined,
|
|
494
494
|
inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
|
|
495
495
|
outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
|
|
496
|
+
fixedOutputs: el.hasAttribute('fixed-outputs'),
|
|
496
497
|
branchFilters: ZnFlowBuilder._parseBranchFilters(el),
|
|
497
498
|
};
|
|
498
499
|
}
|
|
@@ -810,10 +811,20 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
810
811
|
// Centred under the branch drop (where the pill hangs), a full layer below
|
|
811
812
|
// the source (same rhythm as untangle) — the child lands in a straight
|
|
812
813
|
// line under the branch instead of being shoved sideways by collision.
|
|
813
|
-
const x = branchDropXs(source, t => this.registry.get(t))[idx];
|
|
814
|
+
const x = branchDropXs(source, t => this.registry.get(t), this._state.connections)[idx];
|
|
814
815
|
return {x: Math.round(x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
|
|
815
816
|
}
|
|
816
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Whether a node refuses the branch being asked of it: a fixed-output type
|
|
820
|
+
* has only the branches it declares, so the "new branch" sentinel has nowhere
|
|
821
|
+
* to go. Checked before the history push, so a refused gesture is a no-op
|
|
822
|
+
* rather than an empty undo step.
|
|
823
|
+
*/
|
|
824
|
+
private _refusesBranch(node: FlowNodeInstance, port: string): boolean {
|
|
825
|
+
return port === NEW_OUTPUT_PORT && !!this.registry.get(node.type)?.fixedOutputs;
|
|
826
|
+
}
|
|
827
|
+
|
|
817
828
|
/**
|
|
818
829
|
* Resolve an output port id on a node: the "new branch" sentinel materialises a
|
|
819
830
|
* fresh, labelled output port (per-instance), so it exists before connecting.
|
|
@@ -847,6 +858,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
847
858
|
if (!source || !type) return;
|
|
848
859
|
const inPort = typeInputs(type)[0]?.id;
|
|
849
860
|
if (!inPort) return; // an entrypoint takes no inputs, so it can't be attached
|
|
861
|
+
if (this._refusesBranch(source, port)) return;
|
|
850
862
|
this._pushHistory();
|
|
851
863
|
port = this._ensureOutput(source, port);
|
|
852
864
|
this._ensureBranchLabel(source, port);
|
|
@@ -882,6 +894,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
882
894
|
if (!source || !target || sourceId === targetId) return;
|
|
883
895
|
const inPort = firstInputId(target, this.registry.get(target.type));
|
|
884
896
|
if (inPort === null) return;
|
|
897
|
+
if (this._refusesBranch(source, port)) return;
|
|
885
898
|
this._pushHistory();
|
|
886
899
|
port = this._ensureOutput(source, port);
|
|
887
900
|
this._ensureBranchLabel(source, port);
|
|
@@ -970,6 +983,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
970
983
|
private _onBranchDelete = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
971
984
|
const node = this._state.nodes.find(n => n.id === e.detail.nodeId);
|
|
972
985
|
if (!node) return;
|
|
986
|
+
if (this.registry.get(node.type)?.fixedOutputs) return; // its branches are the only ones it has
|
|
973
987
|
this._pushHistory();
|
|
974
988
|
const outputs = nodeOutputs(node, this.registry.get(node.type)).filter(p => p.id !== e.detail.port);
|
|
975
989
|
// Deleting the last branch leaves a plain open output, so the node stays extensible.
|
|
@@ -1041,6 +1055,7 @@ export default class ZnFlowBuilder extends ZincElement {
|
|
|
1041
1055
|
if (!owner || !moving) return;
|
|
1042
1056
|
const inPort = firstInputId(moving, this.registry.get(moving.type));
|
|
1043
1057
|
if (inPort === null) return;
|
|
1058
|
+
if (this._refusesBranch(owner, e.detail.port)) return;
|
|
1044
1059
|
|
|
1045
1060
|
this._pushHistory();
|
|
1046
1061
|
const outPort = this._ensureOutput(owner, e.detail.port);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
2
|
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
-
import type
|
|
3
|
+
import {type FlowNodeType, type FlowState, NEW_OUTPUT_PORT, NODE_HEIGHT, NODE_WIDTH} from './flow.types';
|
|
4
4
|
import type ZnFlowBuilder from './flow-builder.component';
|
|
5
5
|
|
|
6
6
|
const TRIGGER: FlowNodeType = {type: 'webhook', label: 'Webhook', group: 'trigger', category: 'Contacts'};
|
|
@@ -293,4 +293,209 @@ describe('<zn-flow-builder>', () => {
|
|
|
293
293
|
]);
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
describe('fixed outputs', () => {
|
|
297
|
+
const FIXED: FlowNodeType = {
|
|
298
|
+
type: 'step',
|
|
299
|
+
label: 'Step',
|
|
300
|
+
group: 'action',
|
|
301
|
+
fixedOutputs: true,
|
|
302
|
+
outputs: [
|
|
303
|
+
{id: 'success', label: 'Success'},
|
|
304
|
+
{id: 'failure', label: 'Failure'},
|
|
305
|
+
{id: 'skip', label: 'Skip'},
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const wiredState: FlowState = {
|
|
310
|
+
nodes: [
|
|
311
|
+
{id: 'n1', type: 'step', x: 0, y: 0, data: {}},
|
|
312
|
+
{id: 'n2', type: 'step', x: 0, y: 220, data: {}},
|
|
313
|
+
],
|
|
314
|
+
connections: [
|
|
315
|
+
{id: 'c1', source: {node: 'n1', port: 'success'}, target: {node: 'n2', port: 'in'}},
|
|
316
|
+
{id: 'c2', source: {node: 'n1', port: 'failure'}, target: {node: 'n2', port: 'in'}},
|
|
317
|
+
{id: 'c3', source: {node: 'n1', port: 'skip'}, target: {node: 'n2', port: 'in'}},
|
|
318
|
+
],
|
|
319
|
+
notes: [],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const makeFixed = async (state: FlowState) => {
|
|
323
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
324
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
325
|
+
el.registerNodeTypes([FIXED]);
|
|
326
|
+
el.setState(state);
|
|
327
|
+
await el.updateComplete;
|
|
328
|
+
return el;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
it('should refuse to delete one of a fixed type\'s branches', async () => {
|
|
332
|
+
const el = await makeFixed(wiredState);
|
|
333
|
+
el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
|
|
334
|
+
await el.updateComplete;
|
|
335
|
+
|
|
336
|
+
const node = el.getState().nodes[0];
|
|
337
|
+
expect(node.outputs ?? FIXED.outputs).to.have.length(3);
|
|
338
|
+
expect(el.getState().connections).to.have.length(3);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should not offer a delete on a fixed type\'s branch pills', async () => {
|
|
342
|
+
const el = await makeFixed(wiredState);
|
|
343
|
+
const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
|
|
344
|
+
expect(canvas?.shadowRoot?.querySelector('.branch-pill')).to.exist;
|
|
345
|
+
expect(canvas?.shadowRoot?.querySelector('.branch-pill-delete')).to.not.exist;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should not grow a fixed type an extra branch when every one is wired', async () => {
|
|
349
|
+
const el = await makeFixed(wiredState);
|
|
350
|
+
el.dispatchEvent(new CustomEvent('flow-link-assign', {
|
|
351
|
+
detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
|
|
352
|
+
}));
|
|
353
|
+
await el.updateComplete;
|
|
354
|
+
|
|
355
|
+
const node = el.getState().nodes[0];
|
|
356
|
+
expect(node.outputs ?? FIXED.outputs).to.have.length(3);
|
|
357
|
+
expect(el.getState().connections).to.have.length(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should still let an ordinary type grow a branch', async () => {
|
|
361
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
362
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
363
|
+
el.registerNodeTypes([{...FIXED, fixedOutputs: false}]);
|
|
364
|
+
el.setState(wiredState);
|
|
365
|
+
await el.updateComplete;
|
|
366
|
+
|
|
367
|
+
el.dispatchEvent(new CustomEvent('flow-link-assign', {
|
|
368
|
+
detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
|
|
369
|
+
}));
|
|
370
|
+
await el.updateComplete;
|
|
371
|
+
|
|
372
|
+
expect(el.getState().nodes[0].outputs).to.have.length(4);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should read fixed-outputs off a declared step', async () => {
|
|
376
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
377
|
+
<zn-flow-builder>
|
|
378
|
+
<zn-flow-step type="step" group="action" label="Step" fixed-outputs
|
|
379
|
+
outputs='[{"id":"success","label":"Success"},{"id":"failure","label":"Failure"}]'>
|
|
380
|
+
</zn-flow-step>
|
|
381
|
+
</zn-flow-builder>`);
|
|
382
|
+
el.setState({
|
|
383
|
+
nodes: [{id: 'n1', type: 'step', x: 0, y: 0, data: {}}],
|
|
384
|
+
connections: [],
|
|
385
|
+
notes: [],
|
|
386
|
+
});
|
|
387
|
+
await el.updateComplete;
|
|
388
|
+
|
|
389
|
+
el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
|
|
390
|
+
await el.updateComplete;
|
|
391
|
+
expect(el.getState().nodes[0].outputs).to.be.undefined; // untouched: still the type's two
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
describe('wire routing', () => {
|
|
395
|
+
const TRIPLE: FlowNodeType = {
|
|
396
|
+
type: 'step',
|
|
397
|
+
label: 'Step',
|
|
398
|
+
group: 'action',
|
|
399
|
+
fixedOutputs: true,
|
|
400
|
+
outputs: [
|
|
401
|
+
{id: 'success', label: 'Success'},
|
|
402
|
+
{id: 'failure', label: 'Failure'},
|
|
403
|
+
{id: 'skip', label: 'Skip'},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// A's failure skips the middle row entirely and lands on C, while A's other
|
|
408
|
+
// branches and B's success feed the rows in between — the arrangement that
|
|
409
|
+
// drew A's failure wire straight down through B, so it read as though the
|
|
410
|
+
// branch ended at B.
|
|
411
|
+
const SKIP_A_ROW: FlowState = {
|
|
412
|
+
nodes: [
|
|
413
|
+
{id: 'a', type: 'step', x: 200, y: 700, data: {}},
|
|
414
|
+
{id: 'b', type: 'step', x: 200, y: 920, data: {}},
|
|
415
|
+
{id: 'c', type: 'step', x: 200, y: 1140, data: {}},
|
|
416
|
+
],
|
|
417
|
+
connections: [
|
|
418
|
+
{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}},
|
|
419
|
+
{id: 'c2', source: {node: 'a', port: 'skip'}, target: {node: 'b', port: 'in'}},
|
|
420
|
+
{id: 'c3', source: {node: 'a', port: 'failure'}, target: {node: 'c', port: 'in'}},
|
|
421
|
+
{id: 'c4', source: {node: 'b', port: 'success'}, target: {node: 'c', port: 'in'}},
|
|
422
|
+
],
|
|
423
|
+
notes: [],
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const points = (d: string) => {
|
|
427
|
+
const numbers = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
|
428
|
+
const out: { x: number; y: number }[] = [];
|
|
429
|
+
for (let i = 0; i + 1 < numbers.length; i += 2) out.push({x: numbers[i], y: numbers[i + 1]});
|
|
430
|
+
return out;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const wirePaths = async (state: FlowState) => {
|
|
434
|
+
const el = await fixture<ZnFlowBuilder>(html`
|
|
435
|
+
<zn-flow-builder></zn-flow-builder>`);
|
|
436
|
+
el.registerNodeTypes([TRIPLE]);
|
|
437
|
+
el.setState(state);
|
|
438
|
+
await el.updateComplete;
|
|
439
|
+
const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
|
|
440
|
+
await canvas?.updateComplete;
|
|
441
|
+
return Array.from(canvas?.shadowRoot?.querySelectorAll('path.wire--link') ?? [])
|
|
442
|
+
.map(p => points(p.getAttribute('d') ?? ''))
|
|
443
|
+
.filter(pts => pts.length > 1);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Segments are orthogonal, so a crossing is a plain rect overlap. The card's
|
|
447
|
+
// own edges are fair game: wires legitimately end on the top one.
|
|
448
|
+
const crossesCard = (pts: { x: number; y: number }[], card: { x: number; y: number }) => {
|
|
449
|
+
const E = 1;
|
|
450
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
451
|
+
const minX = Math.min(pts[i].x, pts[i + 1].x);
|
|
452
|
+
const maxX = Math.max(pts[i].x, pts[i + 1].x);
|
|
453
|
+
const minY = Math.min(pts[i].y, pts[i + 1].y);
|
|
454
|
+
const maxY = Math.max(pts[i].y, pts[i + 1].y);
|
|
455
|
+
if (minX < card.x + NODE_WIDTH - E && maxX > card.x + E
|
|
456
|
+
&& minY < card.y + NODE_HEIGHT - E && maxY > card.y + E) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
it('should route a row-skipping wire around the card it flies over', async () => {
|
|
464
|
+
const paths = await wirePaths(SKIP_A_ROW);
|
|
465
|
+
const middle = {x: 200, y: 920};
|
|
466
|
+
|
|
467
|
+
expect(paths.length).to.be.greaterThan(0);
|
|
468
|
+
paths.forEach(pts => {
|
|
469
|
+
expect(crossesCard(pts, middle), `wire ${JSON.stringify(pts)} crosses the middle card`)
|
|
470
|
+
.to.equal(false);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should take the row-skipping wire out to the side, not through the column', async () => {
|
|
475
|
+
const paths = await wirePaths(SKIP_A_ROW);
|
|
476
|
+
// A's failure pill sits on A's bus, so its wire is the one leaving highest.
|
|
477
|
+
const skipping = paths.reduce((lowestY, pts) => (pts[0].y < lowestY[0].y ? pts : lowestY));
|
|
478
|
+
|
|
479
|
+
expect(skipping[skipping.length - 1].y).to.equal(1140); // ends on C's input
|
|
480
|
+
expect(skipping.length).to.be.greaterThan(2); // not the straight drop
|
|
481
|
+
// It leaves the corridor the cards occupy (200..440) on its way past.
|
|
482
|
+
const outside = skipping.some(p => p.x > 440 || p.x < 200);
|
|
483
|
+
expect(outside, `expected a detour outside the column, got ${JSON.stringify(skipping)}`).to.equal(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should still draw a straight wire when the corridor is clear', async () => {
|
|
487
|
+
const paths = await wirePaths({
|
|
488
|
+
nodes: [
|
|
489
|
+
{id: 'a', type: 'step', x: 200, y: 700, data: {}},
|
|
490
|
+
{id: 'b', type: 'step', x: 200, y: 920, data: {}},
|
|
491
|
+
],
|
|
492
|
+
connections: [{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}}],
|
|
493
|
+
notes: [],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(paths).to.have.length(1);
|
|
497
|
+
expect(paths[0]).to.have.length(2);
|
|
498
|
+
expect(paths[0][0].x).to.equal(paths[0][1].x);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
296
501
|
});
|