@nyaruka/temba-components 0.170.0 → 0.171.0
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/CHANGELOG.md +8 -0
- package/dist/temba-components.js +766 -744
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/Editor.ts +202 -42
- package/src/flow/dependencies.ts +115 -0
- package/src/form/RangePicker.ts +129 -97
- package/src/list/ContentList.ts +7 -1
- package/src/list/FlowList.ts +82 -0
- package/src/live/Realtime.ts +22 -0
- package/src/store/AppState.ts +97 -10
- package/src/store/Store.ts +495 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
package/src/form/RangePicker.ts
CHANGED
|
@@ -4,107 +4,157 @@ import { DateTime } from 'luxon';
|
|
|
4
4
|
import { html, css, PropertyValues } from 'lit';
|
|
5
5
|
import { DatePicker } from './DatePicker';
|
|
6
6
|
import { CustomEventType } from '../interfaces';
|
|
7
|
+
import { Icon } from '../Icons';
|
|
8
|
+
import { designTokens } from '../styles/designTokens';
|
|
7
9
|
|
|
8
10
|
export class RangePicker extends RapidElement {
|
|
9
11
|
static styles = css`
|
|
12
|
+
${designTokens}
|
|
13
|
+
|
|
14
|
+
:host {
|
|
15
|
+
display: inline-block;
|
|
16
|
+
font-family: var(--font);
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
.range-container {
|
|
11
20
|
display: flex;
|
|
12
|
-
gap:
|
|
21
|
+
gap: 8px;
|
|
13
22
|
align-items: center;
|
|
23
|
+
font-size: 13.5px;
|
|
24
|
+
color: var(--text-1);
|
|
14
25
|
}
|
|
26
|
+
|
|
15
27
|
.date-display {
|
|
16
28
|
cursor: pointer;
|
|
17
|
-
padding:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
padding: 3px 6px;
|
|
30
|
+
border-radius: var(--r-sm);
|
|
31
|
+
transition:
|
|
32
|
+
background 120ms,
|
|
33
|
+
color 120ms;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
.date-display:hover {
|
|
25
|
-
border: 1px solid var(--color-widget-border);
|
|
26
37
|
background: var(--sunken);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
padding: 0.2em 0.5em;
|
|
32
|
-
border-radius: var(--curvature-widget);
|
|
33
|
-
border: 1px solid var(--color-widget-border);
|
|
40
|
+
.range-separator {
|
|
41
|
+
color: var(--text-3);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
.navigation-container {
|
|
37
45
|
display: flex;
|
|
38
46
|
align-items: center;
|
|
39
|
-
gap:
|
|
47
|
+
gap: 4px;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/* Chevron-only step buttons, same chrome as the list pager: bare
|
|
51
|
+
glyph at rest, sunken wash on hover. No border of their own, so
|
|
52
|
+
they don't compete with the period track between them. */
|
|
42
53
|
.nav-arrow {
|
|
43
|
-
|
|
44
|
-
border: 1px solid var(--color-widget-border);
|
|
45
|
-
|
|
46
|
-
border-radius: var(--curvature-widget);
|
|
47
|
-
padding: 0em 0em;
|
|
48
|
-
cursor: pointer;
|
|
49
|
-
font-size: 0.6em;
|
|
50
|
-
display: flex;
|
|
54
|
+
display: inline-flex;
|
|
51
55
|
align-items: center;
|
|
52
56
|
justify-content: center;
|
|
53
|
-
width:
|
|
54
|
-
height:
|
|
57
|
+
width: 24px;
|
|
58
|
+
height: 24px;
|
|
59
|
+
padding: 0;
|
|
60
|
+
border: 0;
|
|
61
|
+
background: transparent;
|
|
62
|
+
border-radius: var(--r-sm);
|
|
63
|
+
color: var(--text-3);
|
|
64
|
+
cursor: pointer;
|
|
55
65
|
transition:
|
|
56
|
-
background
|
|
57
|
-
|
|
58
|
-
opacity
|
|
66
|
+
background 120ms,
|
|
67
|
+
color 120ms,
|
|
68
|
+
opacity 120ms;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.nav-arrow temba-icon {
|
|
72
|
+
--icon-color: currentColor;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
.nav-arrow:hover:not(:disabled) {
|
|
62
|
-
background: var(--
|
|
63
|
-
|
|
76
|
+
background: var(--sunken);
|
|
77
|
+
color: var(--text-1);
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
.nav-arrow:disabled {
|
|
67
|
-
opacity: 0.
|
|
68
|
-
cursor:
|
|
69
|
-
background: var(--sunken);
|
|
81
|
+
opacity: 0.35;
|
|
82
|
+
cursor: default;
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
.nav-arrow.hidden {
|
|
73
86
|
visibility: hidden;
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
/* Segmented control — one bordered sunken track holds every
|
|
90
|
+
period and the selected one lifts out as a surface pill. The
|
|
91
|
+
buttons carry no borders themselves, so there are no
|
|
92
|
+
overlapping 1px edges to clip the selection's outline (the old
|
|
93
|
+
-1px margin stacking left the selected button's left border
|
|
94
|
+
painted over by its neighbor). */
|
|
76
95
|
.button-group {
|
|
77
|
-
display: flex;
|
|
78
|
-
|
|
96
|
+
display: inline-flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 2px;
|
|
99
|
+
padding: 2px;
|
|
100
|
+
background: var(--sunken);
|
|
101
|
+
border: 1px solid var(--border);
|
|
102
|
+
border-radius: var(--r-sm);
|
|
79
103
|
}
|
|
104
|
+
|
|
80
105
|
.range-btn {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
border
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
height: 22px;
|
|
107
|
+
padding: 0 10px;
|
|
108
|
+
border: 0;
|
|
109
|
+
background: transparent;
|
|
110
|
+
border-radius: var(--r-xs);
|
|
111
|
+
font-family: inherit;
|
|
112
|
+
font-size: 12.5px;
|
|
113
|
+
font-weight: var(--w-medium);
|
|
114
|
+
line-height: 1;
|
|
115
|
+
color: var(--text-2);
|
|
86
116
|
cursor: pointer;
|
|
87
|
-
font-size: 0.95em;
|
|
88
117
|
transition:
|
|
89
|
-
background
|
|
90
|
-
|
|
118
|
+
background 120ms,
|
|
119
|
+
color 120ms,
|
|
120
|
+
box-shadow 120ms;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
/* The hover wash sits on the sunken track, so --sunken itself
|
|
124
|
+
would be invisible here. Derived from --text-1 rather than a raw
|
|
125
|
+
rgba literal so it follows the palette (the tokens file mixes
|
|
126
|
+
against transparent the same way for --focus-halo). */
|
|
127
|
+
.range-btn:hover:not(.selected) {
|
|
128
|
+
background: color-mix(in srgb, var(--text-1) 6%, transparent);
|
|
129
|
+
color: var(--text-1);
|
|
95
130
|
}
|
|
96
131
|
|
|
97
|
-
.
|
|
98
|
-
|
|
132
|
+
.range-btn.selected {
|
|
133
|
+
background: var(--surface);
|
|
134
|
+
color: var(--accent-700);
|
|
135
|
+
box-shadow: var(--shadow-1);
|
|
136
|
+
cursor: default;
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
.range-btn
|
|
102
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
.range-btn:focus-visible,
|
|
140
|
+
.nav-arrow:focus-visible {
|
|
141
|
+
outline: none;
|
|
142
|
+
box-shadow: var(--focus-halo);
|
|
105
143
|
}
|
|
106
144
|
`;
|
|
107
145
|
|
|
146
|
+
// the periods in the segmented control, in display order
|
|
147
|
+
private static RANGES: {
|
|
148
|
+
type: 'W' | 'M' | 'Y' | 'ALL';
|
|
149
|
+
label: string;
|
|
150
|
+
title: string;
|
|
151
|
+
}[] = [
|
|
152
|
+
{ type: 'W', label: 'W', title: 'Last week' },
|
|
153
|
+
{ type: 'M', label: 'M', title: 'Last month' },
|
|
154
|
+
{ type: 'Y', label: 'Y', title: 'Last year' },
|
|
155
|
+
{ type: 'ALL', label: 'All', title: 'All time' }
|
|
156
|
+
];
|
|
157
|
+
|
|
108
158
|
@property({ type: String, attribute: 'start' })
|
|
109
159
|
startDate = '';
|
|
110
160
|
|
|
@@ -465,6 +515,15 @@ export class RangePicker extends RapidElement {
|
|
|
465
515
|
} ${amount} ${unit}`;
|
|
466
516
|
}
|
|
467
517
|
|
|
518
|
+
private getNavigationTitle(direction: 'previous' | 'next'): string {
|
|
519
|
+
const prefix = direction === 'previous' ? 'Previous' : 'Next';
|
|
520
|
+
if (this.selectedRange === 'W') return `${prefix} week`;
|
|
521
|
+
if (this.selectedRange === 'M') return `${prefix} month`;
|
|
522
|
+
if (this.selectedRange === 'Y') return `${prefix} year`;
|
|
523
|
+
if (this.selectedRange === '') return this.getNavigationLabel(direction);
|
|
524
|
+
return `${prefix} period`;
|
|
525
|
+
}
|
|
526
|
+
|
|
468
527
|
willUpdate(changed: PropertyValues) {
|
|
469
528
|
super.willUpdate(changed);
|
|
470
529
|
|
|
@@ -543,7 +602,7 @@ export class RangePicker extends RapidElement {
|
|
|
543
602
|
>${this.formatDateForDisplay(this.startDate) ||
|
|
544
603
|
'Start date'}</span
|
|
545
604
|
>`}
|
|
546
|
-
<span
|
|
605
|
+
<span class="range-separator">–</span>
|
|
547
606
|
${this.editingEnd
|
|
548
607
|
? html`<temba-datepicker
|
|
549
608
|
.value=${this.endDate}
|
|
@@ -566,61 +625,34 @@ export class RangePicker extends RapidElement {
|
|
|
566
625
|
class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
|
|
567
626
|
?disabled=${!this.canNavigatePrevious()}
|
|
568
627
|
@click=${this.navigatePrevious}
|
|
569
|
-
title
|
|
570
|
-
|
|
571
|
-
: this.selectedRange === 'M'
|
|
572
|
-
? 'month'
|
|
573
|
-
: this.selectedRange === 'Y'
|
|
574
|
-
? 'year'
|
|
575
|
-
: this.selectedRange === ''
|
|
576
|
-
? this.getNavigationLabel('previous')
|
|
577
|
-
: 'period'}"
|
|
628
|
+
title=${this.getNavigationTitle('previous')}
|
|
629
|
+
aria-label=${this.getNavigationTitle('previous')}
|
|
578
630
|
>
|
|
579
|
-
|
|
631
|
+
<temba-icon name=${Icon.arrow_left}></temba-icon>
|
|
580
632
|
</button>
|
|
581
|
-
<div class="button-group">
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
class="range-btn ${this.selectedRange === 'Y' ? 'selected' : ''}"
|
|
596
|
-
@click=${() => this.setRange('Y')}
|
|
597
|
-
>
|
|
598
|
-
Y
|
|
599
|
-
</button>
|
|
600
|
-
<button
|
|
601
|
-
class="range-btn ${this.selectedRange === 'ALL'
|
|
602
|
-
? 'selected'
|
|
603
|
-
: ''}"
|
|
604
|
-
@click=${() => this.setRange('ALL')}
|
|
605
|
-
>
|
|
606
|
-
All
|
|
607
|
-
</button>
|
|
633
|
+
<div class="button-group" role="group" aria-label="Period">
|
|
634
|
+
${RangePicker.RANGES.map(
|
|
635
|
+
(range) =>
|
|
636
|
+
html`<button
|
|
637
|
+
class="range-btn ${this.selectedRange === range.type
|
|
638
|
+
? 'selected'
|
|
639
|
+
: ''}"
|
|
640
|
+
title=${range.title}
|
|
641
|
+
aria-pressed=${this.selectedRange === range.type}
|
|
642
|
+
@click=${() => this.setRange(range.type)}
|
|
643
|
+
>
|
|
644
|
+
${range.label}
|
|
645
|
+
</button>`
|
|
646
|
+
)}
|
|
608
647
|
</div>
|
|
609
648
|
<button
|
|
610
649
|
class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
|
|
611
650
|
?disabled=${!this.canNavigateNext()}
|
|
612
651
|
@click=${this.navigateNext}
|
|
613
|
-
title
|
|
614
|
-
|
|
615
|
-
: this.selectedRange === 'M'
|
|
616
|
-
? 'month'
|
|
617
|
-
: this.selectedRange === 'Y'
|
|
618
|
-
? 'year'
|
|
619
|
-
: this.selectedRange === ''
|
|
620
|
-
? this.getNavigationLabel('next')
|
|
621
|
-
: 'period'}"
|
|
652
|
+
title=${this.getNavigationTitle('next')}
|
|
653
|
+
aria-label=${this.getNavigationTitle('next')}
|
|
622
654
|
>
|
|
623
|
-
|
|
655
|
+
<temba-icon name=${Icon.arrow_right}></temba-icon>
|
|
624
656
|
</button>
|
|
625
657
|
</div>
|
|
626
658
|
</div>
|
package/src/list/ContentList.ts
CHANGED
|
@@ -1615,6 +1615,12 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1615
1615
|
super();
|
|
1616
1616
|
}
|
|
1617
1617
|
|
|
1618
|
+
/** Gives specialized lists a chance to normalize a fetched page before it
|
|
1619
|
+
* becomes visible. The default keeps the server response unchanged. */
|
|
1620
|
+
protected prepareItems(items: T[]): T[] {
|
|
1621
|
+
return items;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1618
1624
|
protected willUpdate(changes: PropertyValues): void {
|
|
1619
1625
|
super.willUpdate(changes);
|
|
1620
1626
|
if (
|
|
@@ -2021,7 +2027,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2021
2027
|
// empty results) with an `error` message — surface it over the
|
|
2022
2028
|
// empty table rather than the plain empty-state copy.
|
|
2023
2029
|
this.searchError = typeof data.error === 'string' ? data.error : '';
|
|
2024
|
-
this.items = data.results || [];
|
|
2030
|
+
this.items = this.prepareItems(data.results || []);
|
|
2025
2031
|
this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
|
|
2026
2032
|
this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
|
|
2027
2033
|
// Cursor mode is detected from the shape of next/previous,
|
package/src/list/FlowList.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { css, html, TemplateResult } from 'lit';
|
|
|
2
2
|
import { ContentList, ContentListColumn } from './ContentList';
|
|
3
3
|
import { Icon } from '../Icons';
|
|
4
4
|
import { CustomEventType, Flow, ObjectReference } from '../interfaces';
|
|
5
|
+
import { getStore, StoreAsset, StoreAssetChangedEvent } from '../store/Store';
|
|
6
|
+
import { RealtimeSubscription } from '../live/Realtime';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Flow CRUDL list — drop-in replacement for the rapidpro
|
|
@@ -11,6 +13,8 @@ import { CustomEventType, Flow, ObjectReference } from '../interfaces';
|
|
|
11
13
|
* bar, activity sparkline.
|
|
12
14
|
*/
|
|
13
15
|
export class FlowList extends ContentList<Flow> {
|
|
16
|
+
private assetWatch: RealtimeSubscription = null;
|
|
17
|
+
|
|
14
18
|
static get styles() {
|
|
15
19
|
return css`
|
|
16
20
|
${ContentList.styles}
|
|
@@ -149,6 +153,84 @@ export class FlowList extends ContentList<Flow> {
|
|
|
149
153
|
];
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
public connectedCallback(): void {
|
|
157
|
+
super.connectedCallback();
|
|
158
|
+
// rows we already have (if any) - each load re-syncs from prepareItems
|
|
159
|
+
this.syncAssetWatch();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public disconnectedCallback(): void {
|
|
163
|
+
super.disconnectedCallback();
|
|
164
|
+
if (this.assetWatch) {
|
|
165
|
+
this.assetWatch.unsubscribe();
|
|
166
|
+
this.assetWatch = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private syncAssetWatch(): void {
|
|
171
|
+
if (!this.isConnected) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const store = getStore();
|
|
175
|
+
if (!store) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (this.assetWatch) {
|
|
179
|
+
this.assetWatch.unsubscribe();
|
|
180
|
+
}
|
|
181
|
+
this.assetWatch = store.watchAssets(
|
|
182
|
+
(this.items || []).map((flow) => ({ type: 'flow', uuid: flow.uuid })),
|
|
183
|
+
(event: StoreAssetChangedEvent | null) => this.syncFlowNames(event?.asset)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private syncFlowNames(changed?: StoreAsset): void {
|
|
188
|
+
const items = this.withCanonicalFlowNames(this.items, changed);
|
|
189
|
+
if (items !== this.items) {
|
|
190
|
+
this.items = items;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A freshly fetched page is authoritative, so it seeds the store cache
|
|
196
|
+
* rather than being overwritten by it - a cached name can predate this
|
|
197
|
+
* response (e.g. a rename missed while another page was on screen), and
|
|
198
|
+
* writing it back would make the stale name self-reinforcing. Cached names
|
|
199
|
+
* only ever move rows through syncFlowNames, driven by socket changes and
|
|
200
|
+
* the reconnect refresh, which are the paths that are actually newer.
|
|
201
|
+
*/
|
|
202
|
+
protected prepareItems(items: Flow[]): Flow[] {
|
|
203
|
+
getStore()?.cacheAssets(
|
|
204
|
+
items
|
|
205
|
+
.filter((item) => !!item.uuid && typeof item.name === 'string')
|
|
206
|
+
.map((item) => ({
|
|
207
|
+
type: 'flow',
|
|
208
|
+
uuid: item.uuid,
|
|
209
|
+
name: item.name
|
|
210
|
+
}))
|
|
211
|
+
);
|
|
212
|
+
Promise.resolve().then(() => this.syncAssetWatch());
|
|
213
|
+
return items;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private withCanonicalFlowNames(items: Flow[], changed?: StoreAsset): Flow[] {
|
|
217
|
+
const store = getStore();
|
|
218
|
+
let updated = false;
|
|
219
|
+
const canonical = items.map((item) => {
|
|
220
|
+
const asset = changed
|
|
221
|
+
? changed.uuid === item.uuid
|
|
222
|
+
? changed
|
|
223
|
+
: null
|
|
224
|
+
: store?.getAsset('flow', item.uuid);
|
|
225
|
+
if (asset && asset.name !== item.name) {
|
|
226
|
+
updated = true;
|
|
227
|
+
return { ...item, name: asset.name };
|
|
228
|
+
}
|
|
229
|
+
return item;
|
|
230
|
+
});
|
|
231
|
+
return updated ? canonical : items;
|
|
232
|
+
}
|
|
233
|
+
|
|
152
234
|
protected getRowIcon(item: Flow): string | null {
|
|
153
235
|
switch (item?.type) {
|
|
154
236
|
case 'voice':
|
package/src/live/Realtime.ts
CHANGED
|
@@ -115,6 +115,28 @@ export const subscribeToNotifications = (
|
|
|
115
115
|
);
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Workspace-wide state changes shared by every component on the page.
|
|
120
|
+
*/
|
|
121
|
+
export const subscribeToOrganization = (
|
|
122
|
+
onEvent: (event: any) => void,
|
|
123
|
+
onSubscribed?: () => void
|
|
124
|
+
): RealtimeSubscription => {
|
|
125
|
+
return subscribeWhenReady((ctx) => `org:${ctx.org}`, onEvent, onSubscribed);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Realtime events for a flow open in the editor. Needs no page context
|
|
130
|
+
* because the flow UUID uniquely identifies the authorized channel.
|
|
131
|
+
*/
|
|
132
|
+
export const subscribeToFlow = (
|
|
133
|
+
flow: string,
|
|
134
|
+
onEvent: (event: any) => void,
|
|
135
|
+
onSubscribed?: () => void
|
|
136
|
+
): RealtimeSubscription => {
|
|
137
|
+
return subscribeToSocket(`flow:${flow}`, onEvent, onSubscribed);
|
|
138
|
+
};
|
|
139
|
+
|
|
118
140
|
/**
|
|
119
141
|
* A contact's history events, or a ticket's detail events when a ticket is
|
|
120
142
|
* given. Needs no page context so subscribes immediately.
|
package/src/store/AppState.ts
CHANGED
|
@@ -15,10 +15,60 @@ import { immer } from 'zustand/middleware/immer';
|
|
|
15
15
|
import { subscribeWithSelector } from 'zustand/middleware';
|
|
16
16
|
import { property } from 'lit/decorators.js';
|
|
17
17
|
import { produce } from 'immer';
|
|
18
|
+
import {
|
|
19
|
+
FlowDependency,
|
|
20
|
+
replaceDependencies,
|
|
21
|
+
resolveDependencyNames
|
|
22
|
+
} from '../flow/dependencies';
|
|
18
23
|
|
|
19
24
|
export const FLOW_SPEC_VERSION = '14.3';
|
|
20
25
|
const CANVAS_PADDING = 800;
|
|
21
26
|
|
|
27
|
+
// how long a revision load will wait on canonical names before rendering the
|
|
28
|
+
// names embedded in the definition, which the editor heals asynchronously
|
|
29
|
+
const DEPENDENCY_RESOLVE_TIMEOUT = 3000;
|
|
30
|
+
|
|
31
|
+
export type DependencyResolver = (
|
|
32
|
+
dependencies: FlowDependency[]
|
|
33
|
+
) => Promise<FlowDependency[]>;
|
|
34
|
+
|
|
35
|
+
let dependencyResolver: DependencyResolver | null = null;
|
|
36
|
+
|
|
37
|
+
/** Installs the page-level canonical-name resolver and returns the previous
|
|
38
|
+
* resolver so a disconnected store can restore it in tests. */
|
|
39
|
+
export const setDependencyResolver = (
|
|
40
|
+
resolver: DependencyResolver | null
|
|
41
|
+
): DependencyResolver | null => {
|
|
42
|
+
const previous = dependencyResolver;
|
|
43
|
+
dependencyResolver = resolver;
|
|
44
|
+
return previous;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The currently installed resolver, so a store can tell whether it is still
|
|
48
|
+
* the owner before restoring the one it replaced. */
|
|
49
|
+
export const getDependencyResolver = (): DependencyResolver | null =>
|
|
50
|
+
dependencyResolver;
|
|
51
|
+
|
|
52
|
+
/** Resolves with null if the given promise hasn't settled in time. */
|
|
53
|
+
const withTimeout = async <T>(
|
|
54
|
+
promise: Promise<T>,
|
|
55
|
+
timeout: number
|
|
56
|
+
): Promise<T | null> => {
|
|
57
|
+
let timer: any = null;
|
|
58
|
+
try {
|
|
59
|
+
return await Promise.race([
|
|
60
|
+
promise,
|
|
61
|
+
new Promise<null>((resolve) => {
|
|
62
|
+
timer = setTimeout(() => resolve(null), timeout);
|
|
63
|
+
})
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
if (timer !== null) {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
22
72
|
/**
|
|
23
73
|
* Temporary: Reclassify nodes based on whether they contain terminal actions.
|
|
24
74
|
* - execute_actions nodes with a terminal action become "terminal"
|
|
@@ -105,15 +155,6 @@ export interface InfoResult {
|
|
|
105
155
|
node_uuids: string[];
|
|
106
156
|
}
|
|
107
157
|
|
|
108
|
-
export interface ObjectRef {
|
|
109
|
-
uuid: string;
|
|
110
|
-
name: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface TypedObjectRef extends ObjectRef {
|
|
114
|
-
type: string;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
158
|
export interface Language {
|
|
118
159
|
code: string;
|
|
119
160
|
name: string;
|
|
@@ -133,7 +174,7 @@ export interface FlowIssue {
|
|
|
133
174
|
|
|
134
175
|
export interface FlowInfo {
|
|
135
176
|
results: InfoResult[];
|
|
136
|
-
dependencies:
|
|
177
|
+
dependencies: FlowDependency[];
|
|
137
178
|
counts: { nodes: number; languages: number };
|
|
138
179
|
locals: string[];
|
|
139
180
|
issues?: FlowIssue[];
|
|
@@ -220,6 +261,7 @@ export interface AppState {
|
|
|
220
261
|
|
|
221
262
|
setFlowContents: (flow: FlowContents) => void;
|
|
222
263
|
setFlowInfo: (info: FlowInfo) => void;
|
|
264
|
+
updateDependencyNames: (dependencies: FlowDependency[]) => void;
|
|
223
265
|
setRevision: (revision: number) => void;
|
|
224
266
|
setLanguageCode: (languageCode: string) => void;
|
|
225
267
|
setDirtyDate: (date: Date) => void;
|
|
@@ -305,6 +347,31 @@ export const zustand = createStore<AppState>()(
|
|
|
305
347
|
throw new Error('Network response was not ok');
|
|
306
348
|
}
|
|
307
349
|
const data = (await response.json()) as FlowContents;
|
|
350
|
+
if (dependencyResolver && data.info?.dependencies?.length) {
|
|
351
|
+
try {
|
|
352
|
+
// resolving before the first paint avoids a visible flash of stale
|
|
353
|
+
// names, but a slow endpoint must never hold the editor hostage -
|
|
354
|
+
// past the timeout we render and let the editor's watcher heal it
|
|
355
|
+
const canonical = await withTimeout(
|
|
356
|
+
dependencyResolver(data.info.dependencies),
|
|
357
|
+
DEPENDENCY_RESOLVE_TIMEOUT
|
|
358
|
+
);
|
|
359
|
+
const dependencies = canonical
|
|
360
|
+
? replaceDependencies(data.info.dependencies, canonical)
|
|
361
|
+
: null;
|
|
362
|
+
if (dependencies) {
|
|
363
|
+
data.info = { ...data.info, dependencies };
|
|
364
|
+
data.definition = resolveDependencyNames(
|
|
365
|
+
data.definition,
|
|
366
|
+
canonical
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// A name lookup shouldn't make an otherwise valid flow unusable.
|
|
371
|
+
// Keep its embedded names and let a later socket/reconnect heal it.
|
|
372
|
+
console.error('failed to resolve flow dependency names', error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
308
375
|
reclassifyTerminalNodes(data.definition);
|
|
309
376
|
reclassifyVoiceWaitNodes(data.definition);
|
|
310
377
|
const issueMaps = buildIssueMaps(data.info?.issues);
|
|
@@ -438,6 +505,26 @@ export const zustand = createStore<AppState>()(
|
|
|
438
505
|
});
|
|
439
506
|
},
|
|
440
507
|
|
|
508
|
+
updateDependencyNames: (changed: FlowDependency[]) => {
|
|
509
|
+
set((state: AppState) => {
|
|
510
|
+
if (!state.flowInfo || !state.flowDefinition) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const dependencies = replaceDependencies(
|
|
514
|
+
state.flowInfo.dependencies,
|
|
515
|
+
changed
|
|
516
|
+
);
|
|
517
|
+
if (!dependencies) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
state.flowInfo.dependencies = dependencies;
|
|
521
|
+
state.flowDefinition = resolveDependencyNames(
|
|
522
|
+
state.flowDefinition,
|
|
523
|
+
changed
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
|
|
441
528
|
setRevision: (revision: number) => {
|
|
442
529
|
set((state: AppState) => {
|
|
443
530
|
state.flowDefinition.revision = revision;
|