@kubex/zinc 1.1.28 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/custom-elements-manifest.config.js +2 -2
  2. package/dist/custom-elements.json +1910 -362
  3. package/dist/vscode.html-custom-data.json +55 -14
  4. package/dist/web-types.json +185 -32
  5. package/dist/zn.d.ts +684 -16
  6. package/dist/zn.min.js +1325 -929
  7. package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
  8. package/docs/pages/components/flow-builder.md +422 -66
  9. package/docs/pages/components/page-builder.md +167 -0
  10. package/docs/pages/components/settings-container.md +37 -2
  11. package/package.json +1 -1
  12. package/src/components/datepicker/datepicker.scss +0 -10
  13. package/src/components/flow-builder/flow-builder.component.ts +377 -16
  14. package/src/components/flow-builder/flow-builder.scss +398 -250
  15. package/src/components/flow-builder/flow-builder.test.ts +241 -4
  16. package/src/components/flow-builder/flow-layout.ts +42 -17
  17. package/src/components/flow-builder/flow.types.ts +168 -43
  18. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
  19. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
  20. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
  21. package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
  22. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
  23. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
  24. package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
  25. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
  26. package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
  27. package/src/components/header/header.scss +3 -1
  28. package/src/components/icon-picker/icon-picker.component.ts +20 -37
  29. package/src/components/icon-picker/icon-picker.scss +5 -45
  30. package/src/components/page-builder/index.ts +14 -0
  31. package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
  32. package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
  33. package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
  34. package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
  35. package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
  36. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
  37. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
  38. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
  39. package/src/components/page-builder/page-builder.component.ts +1053 -0
  40. package/src/components/page-builder/page-builder.scss +494 -0
  41. package/src/components/page-builder/page-builder.test.ts +464 -0
  42. package/src/components/page-builder/page-registry.ts +48 -0
  43. package/src/components/page-builder/page.types.ts +75 -0
  44. package/src/components/panel/panel.scss +1 -0
  45. package/src/components/settings-container/settings-container.component.ts +74 -9
  46. package/src/components/settings-container/settings-container.scss +75 -0
  47. package/src/components/settings-container/settings-container.test.ts +35 -0
  48. package/src/events/events.ts +2 -0
  49. package/src/events/zn-page-change.ts +9 -0
  50. package/src/events/zn-page-selection-change.ts +7 -0
  51. package/src/zinc.ts +4 -0
  52. package/web-test-runner.config.js +6 -1
@@ -0,0 +1,300 @@
1
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
2
+ import {property, state} from 'lit/decorators.js';
3
+ import ZincElement from '../../../../internal/zinc-element';
4
+ import ZnButton from '../../../button';
5
+ import ZnIcon from '../../../icon';
6
+ import ZnInput from '../../../input';
7
+ import ZnOption from '../../../option';
8
+ import ZnSelect from '../../../select';
9
+
10
+ import {
11
+ type FlowBranchCondition,
12
+ type FlowBranchConditions,
13
+ type FlowBranchFilter,
14
+ type FlowFilterField,
15
+ operatorLabel,
16
+ } from '../../flow.types';
17
+
18
+ import styles from './flow-branch-conditions.scss';
19
+
20
+ /**
21
+ * @summary The built-in branch conditions editor: pick filters from a searchable list, then
22
+ * combine them into AND groups joined by OR. Rendered by `<zn-flow-builder>`'s branch editor
23
+ * for node types that declare `branchFilters`; edits are drafted locally and only applied
24
+ * on Save.
25
+ * @documentation https://zinc.style/components/flow-branch-conditions
26
+ * @status experimental
27
+ * @since 1.0
28
+ *
29
+ * @dependency zn-button
30
+ * @dependency zn-icon
31
+ * @dependency zn-input
32
+ * @dependency zn-option
33
+ * @dependency zn-select
34
+ *
35
+ * @event flow-conditions-save - Emitted on Save with `detail.conditions` (OR groups of AND-ed conditions).
36
+ * @event flow-conditions-cancel - Emitted on Cancel, with the draft discarded.
37
+ *
38
+ * @csspart base - The editor wrapper.
39
+ * @csspart picker - The filter picker (search + list).
40
+ * @csspart conditions - The configured condition groups.
41
+ */
42
+ export default class ZnFlowBranchConditions extends ZincElement {
43
+ static styles: CSSResultGroup = unsafeCSS(styles);
44
+
45
+ static dependencies = {
46
+ 'zn-button': ZnButton,
47
+ 'zn-icon': ZnIcon,
48
+ 'zn-input': ZnInput,
49
+ 'zn-option': ZnOption,
50
+ 'zn-select': ZnSelect,
51
+ };
52
+
53
+ /** The filters available to build conditions from. */
54
+ @property({attribute: false}) filters: FlowBranchFilter[] = [];
55
+
56
+ /** The saved conditions being edited; changes stay in a local draft until Save. */
57
+ @property({attribute: false}) value: FlowBranchConditions = [];
58
+
59
+ @state() private _draft: FlowBranchConditions = [];
60
+ /** Group index the picker adds into (`_draft.length` starts a new OR group), or null when closed. */
61
+ @state() private _picker: number | null = null;
62
+ @state() private _search = '';
63
+
64
+ /** JSON of the last `value` the draft was built from, so re-renders that pass an
65
+ * equivalent value (fresh array refs) don't wipe in-progress edits. */
66
+ private _valueJson = '';
67
+
68
+ protected willUpdate(changed: PropertyValues) {
69
+ super.willUpdate(changed);
70
+ if (changed.has('value')) {
71
+ const json = JSON.stringify(this.value ?? []);
72
+ if (json !== this._valueJson) {
73
+ this._valueJson = json;
74
+ this._draft = JSON.parse(json) as FlowBranchConditions;
75
+ this._picker = null;
76
+ this._search = '';
77
+ }
78
+ }
79
+ }
80
+
81
+ private _emit(name: string, detail?: Record<string, unknown>) {
82
+ this.dispatchEvent(new CustomEvent(name, {bubbles: true, composed: true, detail}));
83
+ }
84
+
85
+ private _save = () => {
86
+ this._emit('flow-conditions-save', {conditions: JSON.parse(JSON.stringify(this._draft)) as FlowBranchConditions});
87
+ };
88
+
89
+ private _cancel = () => {
90
+ this._emit('flow-conditions-cancel');
91
+ };
92
+
93
+ // --- Draft edits ------------------------------------------------------------
94
+
95
+ private _cloneDraft(): FlowBranchConditions {
96
+ return this._draft.map(group => group.map(c => ({...c, values: {...c.values}})));
97
+ }
98
+
99
+ private _addCondition(filter: FlowBranchFilter) {
100
+ const values: FlowBranchCondition['values'] = {};
101
+ filter.fields.forEach(f => {
102
+ values[f.id] = {
103
+ ...(f.operators?.length ? {operator: f.operators[0].value} : {}),
104
+ ...(f.value !== undefined ? {value: f.value} : {}),
105
+ ...(f.units?.length ? {unit: f.units[0].value} : {}),
106
+ };
107
+ });
108
+ const draft = this._cloneDraft();
109
+ const group = Math.min(this._picker ?? 0, draft.length);
110
+ if (group === draft.length) draft.push([]);
111
+ draft[group].push({filter: filter.id, values});
112
+ this._draft = draft;
113
+ this._picker = null;
114
+ this._search = '';
115
+ }
116
+
117
+ private _removeCondition(group: number, index: number) {
118
+ const draft = this._cloneDraft();
119
+ draft[group].splice(index, 1);
120
+ this._draft = draft.filter(g => g.length);
121
+ }
122
+
123
+ private _setField(group: number, index: number, fieldId: string, patch: { operator?: string; value?: string | number; unit?: string }) {
124
+ const draft = this._cloneDraft();
125
+ const condition = draft[group][index];
126
+ condition.values[fieldId] = {...condition.values[fieldId], ...patch};
127
+ this._draft = draft;
128
+ }
129
+
130
+ // --- Rendering --------------------------------------------------------------
131
+
132
+ private _renderPicker() {
133
+ const term = this._search.trim().toLowerCase();
134
+ const matches = this.filters.filter(f => !term || f.label.toLowerCase().includes(term));
135
+
136
+ return html`
137
+ <div part="picker" class="picker">
138
+ <div class="picker__head">
139
+ <span>Add Filters</span>
140
+ ${this._draft.length ? html`
141
+ <zn-button
142
+ class="picker__back"
143
+ icon="x@lu"
144
+ icon-button="small"
145
+ plain
146
+ title="Back to conditions"
147
+ @click="${() => (this._picker = null)}"
148
+ ></zn-button>` : ''}
149
+ </div>
150
+ <zn-input
151
+ class="picker__search"
152
+ placeholder="Search filters..."
153
+ clearable
154
+ .value="${this._search}"
155
+ @zn-input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
156
+ @input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
157
+ >
158
+ <zn-icon slot="suffix" src="search@lu" size="16"></zn-icon>
159
+ </zn-input>
160
+ <div class="picker__list">
161
+ ${matches.length === 0
162
+ ? html`<p class="picker__empty">No matching filters.</p>`
163
+ : matches.map(f => html`
164
+ <button class="picker__item" @click="${() => this._addCondition(f)}">
165
+ <span class="picker__item-text">
166
+ <span class="picker__item-label">${f.label}</span>
167
+ ${f.description ? html`<span class="picker__item-description">${f.description}</span>` : ''}
168
+ </span>
169
+ <zn-icon src="arrow-right@lu" size="16"></zn-icon>
170
+ </button>`)}
171
+ </div>
172
+ </div>
173
+ `;
174
+ }
175
+
176
+ /**
177
+ * A select's minimum width, sized so its longest label never truncates
178
+ * (estimated per char, plus padding and the chevron). When the row can't
179
+ * fit it, flex-wrap gives the select its own full-width line instead.
180
+ */
181
+ private static _selectMinWidth(labels: string[]): number {
182
+ const longest = Math.max(0, ...labels.map(l => l.length));
183
+ return Math.min(260, Math.round(52 + longest * 7.2));
184
+ }
185
+
186
+ private _renderField(condition: FlowBranchCondition, group: number, index: number, field: FlowFilterField) {
187
+ const entry = condition.values[field.id] ?? {};
188
+ const type = field.type ?? (field.options?.length ? 'select' : 'text');
189
+ const set = (patch: { operator?: string; value?: string | number; unit?: string }) =>
190
+ this._setField(group, index, field.id, patch);
191
+
192
+ return html`
193
+ <div class="condition__row">
194
+ ${field.label ? html`<span class="condition__row-label">${field.label}</span>` : ''}
195
+ ${field.operators?.length ? html`
196
+ <zn-select
197
+ class="control control--operator"
198
+ size="small"
199
+ style="min-width:${ZnFlowBranchConditions._selectMinWidth(field.operators.map(o => operatorLabel(o)))}px"
200
+ .value="${entry.operator ?? field.operators[0].value}"
201
+ @zn-change="${(e: Event) => set({operator: String((e.target as ZnSelect).value)})}"
202
+ >
203
+ ${field.operators.map(o => html`
204
+ <zn-option value="${o.value}">${operatorLabel(o)}</zn-option>`)}
205
+ </zn-select>` : ''}
206
+ ${type === 'select' ? html`
207
+ <zn-select
208
+ class="control control--value"
209
+ size="small"
210
+ style="min-width:${ZnFlowBranchConditions._selectMinWidth((field.options ?? []).map(o => o.label ?? o.value))}px"
211
+ placeholder="${field.placeholder ?? ''}"
212
+ .value="${String(entry.value ?? '')}"
213
+ @zn-change="${(e: Event) => set({value: String((e.target as ZnSelect).value)})}"
214
+ >
215
+ ${(field.options ?? []).map(o => html`
216
+ <zn-option value="${o.value}">${o.label ?? o.value}</zn-option>`)}
217
+ </zn-select>` : html`
218
+ <zn-input
219
+ class="control ${type === 'number' ? 'control--number' : 'control--value'}"
220
+ size="small"
221
+ type="${type}"
222
+ .value="${String(entry.value ?? '')}"
223
+ placeholder="${field.placeholder ?? ''}"
224
+ @zn-input="${(e: Event) => {
225
+ const raw = String((e.target as ZnInput).value ?? '');
226
+ set({value: type === 'number' ? Number(raw) : raw});
227
+ }}"
228
+ @input="${(e: Event) => {
229
+ const raw = String((e.target as ZnInput).value ?? '');
230
+ set({value: type === 'number' ? Number(raw) : raw});
231
+ }}"
232
+ ></zn-input>`}
233
+ ${field.units?.length ? html`
234
+ <zn-select
235
+ class="control control--unit"
236
+ size="small"
237
+ style="min-width:${ZnFlowBranchConditions._selectMinWidth(field.units.map(u => u.label ?? u.value))}px"
238
+ .value="${entry.unit ?? field.units[0].value}"
239
+ @zn-change="${(e: Event) => set({unit: String((e.target as ZnSelect).value)})}"
240
+ >
241
+ ${field.units.map(u => html`
242
+ <zn-option value="${u.value}">${u.label ?? u.value}</zn-option>`)}
243
+ </zn-select>`
244
+ : field.suffix ? html`<span class="condition__suffix">${field.suffix}</span>` : ''}
245
+ </div>
246
+ `;
247
+ }
248
+
249
+ private _renderCondition(condition: FlowBranchCondition, group: number, index: number) {
250
+ const filter = this.filters.find(f => f.id === condition.filter);
251
+ return html`
252
+ <div class="condition">
253
+ <div class="condition__head">
254
+ <span class="condition__name">${filter?.label ?? condition.filter}</span>
255
+ <zn-button class="condition__remove" title="Remove filter" icon="x@lu" icon-button="small" plain
256
+ @click="${() => this._removeCondition(group, index)}">
257
+ </zn-button>
258
+ </div>
259
+ ${(filter?.fields ?? []).map(f => this._renderField(condition, group, index, f))}
260
+ </div>
261
+ `;
262
+ }
263
+
264
+ private _renderConditions() {
265
+ return html`
266
+ <div part="conditions" class="conditions">
267
+ ${this._draft.map((group, gi) => html`
268
+ ${gi > 0 ? html`
269
+ <div class="or-label">OR</div>` : ''}
270
+ <div class="group">
271
+ ${group.map((condition, ci) => html`
272
+ ${ci > 0 ? html`
273
+ <div class="and-rule"><span>AND</span></div>` : ''}
274
+ ${this._renderCondition(condition, gi, ci)}
275
+ `)}
276
+ <zn-button icon="plus@lu" @click="${() => (this._picker = gi)}" panel-bg>AND
277
+ </zn-button>
278
+ </div>
279
+ `)}
280
+ <zn-button icon="plus@lu" @click="${() => (this._picker = this._draft.length)}" panel-bg>
281
+ OR
282
+ </zn-button>
283
+ </div>
284
+ `;
285
+ }
286
+
287
+ render() {
288
+ // With nothing configured yet, the picker is the first step (as designed).
289
+ const picking = this._picker !== null || this._draft.length === 0;
290
+ return html`
291
+ <div part="base" class="editor">
292
+ ${picking ? this._renderPicker() : this._renderConditions()}
293
+ <div class="actions">
294
+ <zn-button class="actions__save" icon="check@lu" @click="${this._save}">Save</zn-button>
295
+ <zn-button class="actions__cancel" icon="x@lu" @click="${this._cancel}" panel-bg>Cancel</zn-button>
296
+ </div>
297
+ </div>
298
+ `;
299
+ }
300
+ }
@@ -0,0 +1,222 @@
1
+ @use "../../../../wc";
2
+
3
+ :host {
4
+ display: block;
5
+ font-size: 0.8125rem;
6
+ }
7
+
8
+ .editor {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 14px;
12
+ }
13
+
14
+ // --- Filter picker (step 1) --------------------------------------------------
15
+
16
+ .picker {
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: 10px;
20
+ padding: 12px;
21
+ border: 1px solid rgb(var(--zn-border-color));
22
+ border-radius: 8px;
23
+ background: rgb(var(--zn-panel));
24
+
25
+ &__head {
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ font-weight: 600;
30
+ }
31
+
32
+ &__back {
33
+ flex: 0 0 auto;
34
+ }
35
+
36
+ &__list {
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ &__item {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ gap: 10px;
46
+ width: 100%;
47
+ padding: 10px 12px;
48
+ margin-top: -1px;
49
+ border: 1px solid rgb(var(--zn-border-color));
50
+ background: rgb(var(--zn-panel));
51
+ color: rgb(var(--zn-text));
52
+ font-size: 0.8125rem;
53
+ text-align: left;
54
+ cursor: pointer;
55
+
56
+ &:first-child {
57
+ margin-top: 0;
58
+ border-top-left-radius: 8px;
59
+ border-top-right-radius: 8px;
60
+ }
61
+
62
+ &:last-child {
63
+ border-bottom-left-radius: 8px;
64
+ border-bottom-right-radius: 8px;
65
+ }
66
+
67
+ &:hover {
68
+ background: rgb(var(--zn-color-tab));
69
+ }
70
+ }
71
+
72
+ &__item-text {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 2px;
76
+ min-width: 0;
77
+ }
78
+
79
+ &__item-label {
80
+ font-weight: 500;
81
+ }
82
+
83
+ &__item-description {
84
+ font-size: 0.75rem;
85
+ color: rgb(var(--zn-color-muted-text));
86
+ }
87
+
88
+ &__empty {
89
+ margin: 0;
90
+ padding: 10px 2px;
91
+ font-size: 0.75rem;
92
+ color: rgb(var(--zn-color-muted-text));
93
+ }
94
+ }
95
+
96
+ // --- Condition groups (step 2) -----------------------------------------------
97
+
98
+ .conditions {
99
+ display: flex;
100
+ flex-direction: column;
101
+ align-items: flex-start;
102
+ gap: 12px;
103
+ }
104
+
105
+ .group {
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: flex-start;
109
+ gap: 12px;
110
+ width: 100%;
111
+ padding: 14px;
112
+ border: 1px solid rgb(var(--zn-border-color));
113
+ border-radius: 8px;
114
+ background: rgb(var(--zn-panel));
115
+ }
116
+
117
+ // The AND joining two conditions in a group: a label on a full-width rule.
118
+ .and-rule {
119
+ @include wc.text-style(paragraph);
120
+
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 10px;
124
+ width: 100%;
125
+ color: rgb(var(--zn-color-success));
126
+
127
+ &::after {
128
+ content: "";
129
+ flex: 1;
130
+ height: 1px;
131
+ background: color-mix(in srgb, rgb(var(--zn-color-success)) 50%, transparent);
132
+ }
133
+ }
134
+
135
+ // The OR joining two groups.
136
+ .or-label {
137
+ @include wc.text-style(paragraph);
138
+
139
+ margin: 2px 0;
140
+ color: rgb(var(--zn-color-error));
141
+ }
142
+
143
+ .condition {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 10px;
147
+ width: 100%;
148
+
149
+ &__head {
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: space-between;
153
+ gap: 10px;
154
+ width: 100%;
155
+ }
156
+
157
+ &__name {
158
+ @include wc.text-style(heading-section);
159
+ }
160
+
161
+ &__remove {
162
+ flex: 0 0 auto;
163
+ }
164
+
165
+ &__row {
166
+ display: flex;
167
+ align-items: center;
168
+ flex-wrap: wrap;
169
+ gap: 6px;
170
+ }
171
+
172
+ &__row-label,
173
+ &__suffix {
174
+ font-size: 0.75rem;
175
+ color: rgb(var(--zn-color-muted-text));
176
+ white-space: nowrap;
177
+ }
178
+ }
179
+
180
+ // Zinc form controls sized into the row: a zero flex-basis overrides the
181
+ // controls' natural 100% width so a row shares one line. Each select also gets
182
+ // an inline min-width sized to its longest label, so a row that can't show a
183
+ // label in full wraps and gives the select its own line instead of truncating.
184
+ .control {
185
+ min-width: 0;
186
+
187
+ &--operator {
188
+ flex: 1 1 0;
189
+ min-width: 120px;
190
+ }
191
+
192
+ &--number {
193
+ flex: 0 0 auto;
194
+ width: 80px;
195
+ }
196
+
197
+ // Adjustable unit dropdown after the value (e.g. day(s) / month(s)).
198
+ &--unit {
199
+ flex: 1 1 0;
200
+ }
201
+
202
+ // The value control soaks up the remaining row width.
203
+ &--value {
204
+ flex: 1 1 0;
205
+ min-width: 100px;
206
+ }
207
+ }
208
+
209
+ // Graceful fallback for labels past the min-width cap.
210
+ zn-select.control::part(display-input) {
211
+ text-overflow: ellipsis;
212
+ }
213
+
214
+ // Save / Cancel sit in their own section, clear of the last group's "+ OR".
215
+ .actions {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 8px;
219
+ margin-top: 2px;
220
+ padding-top: 16px;
221
+ border-top: 1px solid rgb(var(--zn-border-color));
222
+ }
@@ -0,0 +1,125 @@
1
+ import '../../../../../dist/zn.min.js';
2
+ import {expect, fixture, html, oneEvent} from '@open-wc/testing';
3
+ import type {FlowBranchConditions, FlowBranchFilter} from '../../flow.types';
4
+ import type ZnFlowBranchConditions from './flow-branch-conditions.component';
5
+ import type ZnInput from '../../../input';
6
+
7
+ const FILTERS: FlowBranchFilter[] = [
8
+ {
9
+ id: 'engagement',
10
+ label: 'Email engagement',
11
+ fields: [
12
+ {
13
+ id: 'count',
14
+ type: 'number',
15
+ // 'gte' has no label — the editor falls back to its well-known default.
16
+ operators: [{value: 'gte'}, {value: 'at-most', label: 'at most'}],
17
+ units: [{value: 'days', label: 'day(s)'}, {value: 'months', label: 'month(s)'}],
18
+ value: 2,
19
+ },
20
+ ],
21
+ },
22
+ {
23
+ id: 'lost-reason',
24
+ label: 'Lost reason',
25
+ fields: [
26
+ {
27
+ id: 'reason',
28
+ type: 'select',
29
+ operators: [{value: 'eq', label: 'Is equal to'}, {value: 'neq', label: 'Is not equal to'}],
30
+ options: [{value: 'price', label: 'Price'}, {value: 'competitor', label: 'Competitor'}],
31
+ },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ // zn-button overrides click() without dispatching an event, so raise a real one.
37
+ function clickButton(el: Element | null | undefined) {
38
+ el?.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
39
+ }
40
+
41
+ async function makeEditor(value: FlowBranchConditions = []): Promise<ZnFlowBranchConditions> {
42
+ const el = await fixture<ZnFlowBranchConditions>(html`
43
+ <zn-flow-branch-conditions .filters="${FILTERS}" .value="${value}"></zn-flow-branch-conditions>`);
44
+ await el.updateComplete;
45
+ return el;
46
+ }
47
+
48
+ describe('<zn-flow-branch-conditions>', () => {
49
+ it('should open on the filter picker when nothing is configured', async () => {
50
+ const el = await makeEditor();
51
+ const items = el.shadowRoot?.querySelectorAll('.picker__item');
52
+ expect(el.shadowRoot?.querySelector('.picker')).to.exist;
53
+ expect(items?.length).to.equal(2);
54
+ expect(items?.[0].textContent).to.contain('Email engagement');
55
+ });
56
+
57
+ it('should add a condition with field defaults when a filter is picked', async () => {
58
+ const el = await makeEditor();
59
+ (el.shadowRoot?.querySelectorAll('.picker__item')[0] as HTMLButtonElement).click();
60
+ await el.updateComplete;
61
+
62
+ const condition = el.shadowRoot?.querySelector('.condition');
63
+ expect(condition?.querySelector('.condition__name')?.textContent).to.contain('Email engagement');
64
+ expect(String(condition!.querySelector<ZnInput>('zn-input.control')!.value)).to.equal('2');
65
+ // A label-less well-known operator key renders its default label.
66
+ expect(condition!.querySelector('zn-select.control--operator zn-option')!.textContent)
67
+ .to.contain('Greater Than or Equal To');
68
+ // The adjustable unit dropdown defaults to the first unit.
69
+ const unit = condition!.querySelector('zn-select.control--unit') as Element & { value: string };
70
+ expect(String(unit.value)).to.equal('days');
71
+ });
72
+
73
+ it('should render saved conditions with OR groups and AND rules', async () => {
74
+ const el = await makeEditor([
75
+ [
76
+ {filter: 'engagement', values: {count: {operator: 'gte', value: 2}}},
77
+ {filter: 'lost-reason', values: {reason: {operator: 'eq', value: 'price'}}},
78
+ ],
79
+ [{filter: 'engagement', values: {count: {operator: 'at-most', value: 5}}}],
80
+ ]);
81
+
82
+ expect(el.shadowRoot?.querySelectorAll('.group').length).to.equal(2);
83
+ expect(el.shadowRoot?.querySelectorAll('.condition').length).to.equal(3);
84
+ expect(el.shadowRoot?.querySelectorAll('.and-rule').length).to.equal(1);
85
+ expect(el.shadowRoot?.querySelectorAll('.or-label').length).to.equal(1);
86
+ });
87
+
88
+ it('should emit flow-conditions-save with the draft on Save', async () => {
89
+ const el = await makeEditor();
90
+ (el.shadowRoot?.querySelectorAll('.picker__item')[1] as HTMLButtonElement).click();
91
+ await el.updateComplete;
92
+
93
+ setTimeout(() => clickButton(el.shadowRoot?.querySelector('.actions__save')));
94
+ const event = await oneEvent(el, 'flow-conditions-save') as CustomEvent<{ conditions: FlowBranchConditions }>;
95
+
96
+ expect(event.detail.conditions).to.deep.equal([
97
+ [{filter: 'lost-reason', values: {reason: {operator: 'eq'}}}],
98
+ ]);
99
+ });
100
+
101
+ it('should emit flow-conditions-cancel without mutating the saved value', async () => {
102
+ const saved: FlowBranchConditions = [[{filter: 'engagement', values: {count: {value: 2}}}]];
103
+ const el = await makeEditor(saved);
104
+ clickButton(el.shadowRoot!.querySelector('.condition__remove'));
105
+ await el.updateComplete;
106
+
107
+ setTimeout(() => clickButton(el.shadowRoot?.querySelector('.actions__cancel')));
108
+ await oneEvent(el, 'flow-conditions-cancel');
109
+
110
+ expect(saved[0]).to.have.length(1);
111
+ expect(el.value).to.deep.equal(saved);
112
+ });
113
+
114
+ it('should filter the picker list by the search term', async () => {
115
+ const el = await makeEditor();
116
+ const search = el.shadowRoot!.querySelector<ZnInput>('.picker__search')!;
117
+ search.value = 'lost';
118
+ search.dispatchEvent(new Event('input', {bubbles: true}));
119
+ await el.updateComplete;
120
+
121
+ const items = el.shadowRoot?.querySelectorAll('.picker__item');
122
+ expect(items?.length).to.equal(1);
123
+ expect(items?.[0].textContent).to.contain('Lost reason');
124
+ });
125
+ });
@@ -0,0 +1,12 @@
1
+ import ZnFlowBranchConditions from './flow-branch-conditions.component';
2
+
3
+ export * from './flow-branch-conditions.component';
4
+ export default ZnFlowBranchConditions;
5
+
6
+ ZnFlowBranchConditions.define('zn-flow-branch-conditions');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-flow-branch-conditions': ZnFlowBranchConditions;
11
+ }
12
+ }