@nyaruka/temba-components 0.169.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 +21 -0
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +636 -516
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +2 -2
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/flow/Editor.ts +202 -42
- package/src/flow/dependencies.ts +115 -0
- package/src/form/Compose.ts +37 -28
- package/src/form/RangePicker.ts +129 -97
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +3 -1
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +358 -9
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/FlowList.ts +82 -0
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/ContactChat.ts +7 -1
- package/src/live/Realtime.ts +22 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/simulator/Simulator.ts +1 -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/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
package/src/list/TembaList.ts
CHANGED
|
@@ -56,6 +56,9 @@ export class TembaList extends RapidElement {
|
|
|
56
56
|
@property({ attribute: false })
|
|
57
57
|
renderOption: (option: any, selected: boolean) => TemplateResult;
|
|
58
58
|
|
|
59
|
+
@property({ attribute: false })
|
|
60
|
+
renderDivider: (prev: any, option: any) => TemplateResult | null;
|
|
61
|
+
|
|
59
62
|
@property({ attribute: false })
|
|
60
63
|
renderOptionDetail: (option: any, selected: boolean) => TemplateResult;
|
|
61
64
|
|
|
@@ -68,6 +71,10 @@ export class TembaList extends RapidElement {
|
|
|
68
71
|
|
|
69
72
|
reverseRefresh = true;
|
|
70
73
|
|
|
74
|
+
// subclasses can enforce a display order when refreshed items are merged in,
|
|
75
|
+
// otherwise new items simply land at the top of the list
|
|
76
|
+
protected compareItems: (a: any, b: any) => number = null;
|
|
77
|
+
|
|
71
78
|
// subclasses that get realtime updates can opt out of interval polling
|
|
72
79
|
protected pollingEnabled = true;
|
|
73
80
|
|
|
@@ -251,6 +258,24 @@ export class TembaList extends RapidElement {
|
|
|
251
258
|
return Promise.resolve(results);
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Finds where the item our cursor was pinned to ended up after a merge
|
|
263
|
+
* re-ordered our list. Returns null if the cursor is still on the right item,
|
|
264
|
+
* otherwise the index it moved to (-1 if it is no longer in the list).
|
|
265
|
+
*/
|
|
266
|
+
private findRepinnedCursorIndex(prevItem: any, newItems: any[]): number {
|
|
267
|
+
if (!prevItem) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const prevValue = this.getValue(prevItem);
|
|
272
|
+
if (prevValue === this.getValue(newItems[this.cursorIndex])) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return newItems.findIndex((option) => this.getValue(option) === prevValue);
|
|
277
|
+
}
|
|
278
|
+
|
|
254
279
|
/**
|
|
255
280
|
* Refreshes the first page, updating any found items in our list
|
|
256
281
|
*/
|
|
@@ -303,7 +328,15 @@ export class TembaList extends RapidElement {
|
|
|
303
328
|
}
|
|
304
329
|
const newItems = [...results, ...items];
|
|
305
330
|
|
|
331
|
+
// capture the top item before any display sort - it means "the newest
|
|
332
|
+
// item we just fetched, or the previous top if we fetched nothing" and
|
|
333
|
+
// drives the Refreshed event
|
|
306
334
|
const topItem = newItems[0];
|
|
335
|
+
|
|
336
|
+
if (this.compareItems) {
|
|
337
|
+
newItems.sort(this.compareItems);
|
|
338
|
+
}
|
|
339
|
+
|
|
307
340
|
if (
|
|
308
341
|
!this.mostRecentItem ||
|
|
309
342
|
JSON.stringify(this.mostRecentItem) !== JSON.stringify(topItem)
|
|
@@ -311,27 +344,21 @@ export class TembaList extends RapidElement {
|
|
|
311
344
|
this.mostRecentItem = topItem;
|
|
312
345
|
}
|
|
313
346
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (options) {
|
|
327
|
-
const option =
|
|
328
|
-
options.shadowRoot.querySelector('.option.focused');
|
|
329
|
-
if (option) {
|
|
330
|
-
option.scrollIntoView({ block: 'end', inline: 'nearest' });
|
|
331
|
-
}
|
|
347
|
+
const newIndex = this.findRepinnedCursorIndex(prevItem, newItems);
|
|
348
|
+
if (newIndex !== null && newIndex > -1) {
|
|
349
|
+
this.cursorIndex = newIndex;
|
|
350
|
+
|
|
351
|
+
// make sure our focused item is visible
|
|
352
|
+
window.setTimeout(() => {
|
|
353
|
+
const options = this.shadowRoot.querySelector('temba-options');
|
|
354
|
+
if (options) {
|
|
355
|
+
const option =
|
|
356
|
+
options.shadowRoot.querySelector('.option.focused');
|
|
357
|
+
if (option) {
|
|
358
|
+
option.scrollIntoView({ block: 'end', inline: 'nearest' });
|
|
332
359
|
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
360
|
+
}
|
|
361
|
+
}, 0);
|
|
335
362
|
}
|
|
336
363
|
|
|
337
364
|
this.items = newItems;
|
|
@@ -457,18 +484,60 @@ export class TembaList extends RapidElement {
|
|
|
457
484
|
private handleScrollThreshold() {
|
|
458
485
|
if (this.nextPage && !this.loading) {
|
|
459
486
|
this.loading = true;
|
|
460
|
-
fetchResultsPage(this.nextPage)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
sanitizedResults
|
|
464
|
-
|
|
487
|
+
fetchResultsPage(this.nextPage)
|
|
488
|
+
.then((page: ResultsPage) => {
|
|
489
|
+
return this.sanitizeResults(page.results).then(
|
|
490
|
+
(sanitizedResults: any[]) => {
|
|
491
|
+
if (this.sanitizeOption) {
|
|
492
|
+
sanitizedResults.forEach(this.sanitizeOption);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// the item our cursor is pinned to, sorting can move it
|
|
496
|
+
const prevItem = this.items[this.cursorIndex];
|
|
497
|
+
|
|
498
|
+
// drop anything we already have, a poll can pull an item from the
|
|
499
|
+
// next page up into our loaded window before we fetch it
|
|
500
|
+
const seen = new Set(
|
|
501
|
+
this.items.map((option) => this.getValue(option))
|
|
502
|
+
);
|
|
503
|
+
const appended = (sanitizedResults || []).filter(
|
|
504
|
+
(option: any) => {
|
|
505
|
+
const value = this.getValue(option);
|
|
506
|
+
if (seen.has(value)) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
seen.add(value);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
);
|
|
465
513
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
514
|
+
const items = [...this.items, ...appended];
|
|
515
|
+
|
|
516
|
+
// the server pages in the same total order we display in, so this is
|
|
517
|
+
// normally a no-op, but a poll can re-sort an item (say a ticket that
|
|
518
|
+
// just closed) into the loaded window - without this the appended
|
|
519
|
+
// page would land below it
|
|
520
|
+
if (this.compareItems) {
|
|
521
|
+
items.sort(this.compareItems);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// the sort can shift our selection, keep the cursor on it
|
|
525
|
+
const newIndex = this.findRepinnedCursorIndex(prevItem, items);
|
|
526
|
+
if (newIndex !== null && newIndex > -1) {
|
|
527
|
+
this.cursorIndex = newIndex;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
this.items = items;
|
|
531
|
+
this.nextPage = page.next;
|
|
532
|
+
this.pages++;
|
|
533
|
+
this.loading = false;
|
|
534
|
+
}
|
|
535
|
+
);
|
|
536
|
+
})
|
|
537
|
+
.catch(() => {
|
|
538
|
+
// let the next scroll try again instead of wedging on loading
|
|
469
539
|
this.loading = false;
|
|
470
540
|
});
|
|
471
|
-
});
|
|
472
541
|
}
|
|
473
542
|
}
|
|
474
543
|
|
|
@@ -512,6 +581,7 @@ export class TembaList extends RapidElement {
|
|
|
512
581
|
?loading=${this.loading}
|
|
513
582
|
?internalFocusDisabled=${this.internalFocusDisabled}
|
|
514
583
|
.renderOption=${this.renderOption}
|
|
584
|
+
.renderDivider=${this.renderDivider}
|
|
515
585
|
.renderOptionDetail=${this.renderOptionDetail}
|
|
516
586
|
@temba-scroll-threshold=${this.handleScrollThreshold}
|
|
517
587
|
@temba-selection=${this.handleSelection.bind(this)}
|
package/src/list/TicketList.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html, TemplateResult } from 'lit';
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
|
+
import { msg } from '@lit/localize';
|
|
3
4
|
import { TembaList } from './TembaList';
|
|
4
5
|
import { Contact } from '../interfaces';
|
|
5
6
|
import { Icon } from '../Icons';
|
|
@@ -9,15 +10,17 @@ export class TicketList extends TembaList {
|
|
|
9
10
|
agent = '';
|
|
10
11
|
|
|
11
12
|
public getRefreshEndpoint() {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// open tickets sort above closed ones, so the newest activity can be
|
|
14
|
+
// anywhere in the list, not just the top. Skip anything unparseable so a
|
|
15
|
+
// single bad timestamp can't poison the cursor with NaN
|
|
16
|
+
const lastActivity = this.items.reduce((newest, item) => {
|
|
17
|
+
const activity = new Date(item.ticket.last_activity_on).getTime();
|
|
18
|
+
return isNaN(activity) ? newest : Math.max(newest, activity);
|
|
19
|
+
}, 0);
|
|
20
|
+
|
|
21
|
+
if (lastActivity > 0) {
|
|
14
22
|
const separator = this.endpoint.includes('?') ? '&' : '?';
|
|
15
|
-
return
|
|
16
|
-
this.endpoint +
|
|
17
|
-
separator +
|
|
18
|
-
'after=' +
|
|
19
|
-
new Date(lastActivity).getTime() * 1000
|
|
20
|
-
);
|
|
23
|
+
return this.endpoint + separator + 'after=' + lastActivity * 1000;
|
|
21
24
|
}
|
|
22
25
|
return this.endpoint;
|
|
23
26
|
}
|
|
@@ -35,10 +38,79 @@ export class TicketList extends TembaList {
|
|
|
35
38
|
super();
|
|
36
39
|
|
|
37
40
|
this.valueKey = 'ticket.uuid';
|
|
38
|
-
|
|
41
|
+
// the refresh feed is served oldest first - the inherited reverseRefresh
|
|
42
|
+
// keeps mostRecentItem meaning the newest fetched item (compareItems
|
|
43
|
+
// re-sorts the merged list immediately anyway)
|
|
44
|
+
|
|
45
|
+
this.compareItems = (a: Contact, b: Contact): number => {
|
|
46
|
+
const aClosed = !!a.ticket.closed_on;
|
|
47
|
+
const bClosed = !!b.ticket.closed_on;
|
|
48
|
+
if (aClosed !== bClosed) {
|
|
49
|
+
return aClosed ? 1 : -1;
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
new Date(b.ticket.last_activity_on).getTime() -
|
|
53
|
+
new Date(a.ticket.last_activity_on).getTime()
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
// a quiet label marks where the list crosses from open into closed
|
|
57
|
+
// tickets - the rows themselves then only need gentle muting
|
|
58
|
+
this.renderDivider = (
|
|
59
|
+
prev: Contact,
|
|
60
|
+
contact: Contact
|
|
61
|
+
): TemplateResult | null => {
|
|
62
|
+
if (!contact.ticket.closed_on || (prev && prev.ticket.closed_on)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const closed = msg('Closed');
|
|
66
|
+
return html`
|
|
67
|
+
<div
|
|
68
|
+
role="separator"
|
|
69
|
+
aria-label=${closed}
|
|
70
|
+
style="display:flex; align-items:center; gap:0.6em; padding:0.9em var(--pad) 0.2em var(--pad);"
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
style="font-size:0.7em; font-weight:500; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-4);"
|
|
74
|
+
>
|
|
75
|
+
${closed}
|
|
76
|
+
</div>
|
|
77
|
+
<div
|
|
78
|
+
style="flex-grow:1; height:1px; background:var(--color-widget-border);"
|
|
79
|
+
></div>
|
|
80
|
+
</div>
|
|
81
|
+
`;
|
|
82
|
+
};
|
|
83
|
+
this.renderOption = (
|
|
84
|
+
contact: Contact,
|
|
85
|
+
selected: boolean
|
|
86
|
+
): TemplateResult => {
|
|
87
|
+
// closed tickets are settled history - a compact single line that
|
|
88
|
+
// recedes behind the open work above it
|
|
89
|
+
if (contact.ticket.closed_on) {
|
|
90
|
+
return html`
|
|
91
|
+
<div
|
|
92
|
+
style="display:flex; align-items:baseline; margin-top:0.1em; margin-bottom:0.1em; ${selected
|
|
93
|
+
? ''
|
|
94
|
+
: 'color:var(--text-3);'}"
|
|
95
|
+
>
|
|
96
|
+
<div
|
|
97
|
+
style="flex:1; min-width:0; font-weight:400; line-height:1.6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"
|
|
98
|
+
>
|
|
99
|
+
${contact.name}
|
|
100
|
+
</div>
|
|
101
|
+
<div style="font-size:0.8em; margin-left:0.75em;">
|
|
102
|
+
<temba-date
|
|
103
|
+
value=${contact.ticket.closed_on}
|
|
104
|
+
display="duration"
|
|
105
|
+
></temba-date>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
39
111
|
return html`
|
|
40
112
|
<div
|
|
41
|
-
style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em"
|
|
113
|
+
style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em;"
|
|
42
114
|
>
|
|
43
115
|
<div
|
|
44
116
|
style="display:flex; align-items: flex-start;border:0px solid red;"
|
|
@@ -49,10 +121,8 @@ export class TicketList extends TembaList {
|
|
|
49
121
|
>
|
|
50
122
|
${contact.name}
|
|
51
123
|
</div>
|
|
52
|
-
${contact.
|
|
53
|
-
?
|
|
54
|
-
: contact.last_msg
|
|
55
|
-
? html`
|
|
124
|
+
${contact.last_msg
|
|
125
|
+
? html`
|
|
56
126
|
<div
|
|
57
127
|
style="font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;"
|
|
58
128
|
>
|
|
@@ -81,13 +151,13 @@ export class TicketList extends TembaList {
|
|
|
81
151
|
}
|
|
82
152
|
</div></div>
|
|
83
153
|
`
|
|
84
|
-
|
|
154
|
+
: null}
|
|
85
155
|
</div>
|
|
86
156
|
<div
|
|
87
157
|
style="margin-right: -5px; margin-top: 0px;display:flex;flex-direction:column;align-items:flex-end;max-width:60px;min-width:30px;border:0px solid green;text-align:right"
|
|
88
158
|
>
|
|
89
159
|
<div>
|
|
90
|
-
${
|
|
160
|
+
${contact.ticket.assignee
|
|
91
161
|
? html`<temba-user
|
|
92
162
|
name=${contact.ticket.assignee.name}
|
|
93
163
|
email=${contact.ticket.assignee.email}
|
|
@@ -101,8 +171,7 @@ export class TicketList extends TembaList {
|
|
|
101
171
|
|
|
102
172
|
<div style="font-size:0.8em;text-align:right;border:0px solid red;">
|
|
103
173
|
<temba-date
|
|
104
|
-
value=${contact.ticket.
|
|
105
|
-
contact.ticket.last_activity_on}
|
|
174
|
+
value=${contact.ticket.last_activity_on}
|
|
106
175
|
display="duration"
|
|
107
176
|
></temba-date>
|
|
108
177
|
</div>
|
package/src/live/ContactChat.ts
CHANGED
|
@@ -883,9 +883,15 @@ export class ContactChat extends ContactStoreElement {
|
|
|
883
883
|
return null;
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
+
// unparseable values and zero-value times (e.g. go's zero time for a
|
|
887
|
+
// contact that has never been seen) mean there's no last seen to show
|
|
888
|
+
const lastSeen = DateTime.fromISO(lastSeenOn);
|
|
889
|
+
if (!lastSeen.isValid || lastSeen.year <= 1) {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
886
893
|
// recent activity speaks for itself in the chat - only surface last
|
|
887
894
|
// seen once the contact has been quiet for at least an hour
|
|
888
|
-
const lastSeen = DateTime.fromISO(lastSeenOn);
|
|
889
895
|
const minutes = DateTime.now().diff(lastSeen, 'minutes').minutes;
|
|
890
896
|
if (minutes < 60) {
|
|
891
897
|
return null;
|
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/locales/es.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
15
|
's7722a91d3a512442': str`Last seen ${0}`,
|
|
16
16
|
's22965bb9808befb0': `Interrupt`,
|
|
17
|
+
's6dbbe2646b239ca5': `Closed`,
|
|
17
18
|
's122d4de68bcfcdf4': `It's okay to restart`,
|
|
18
19
|
's3eb2567092b4d7c1': `from the beginning`,
|
|
19
20
|
's28f37776b3901438': `It's okay to interrupt`,
|
package/src/locales/fr.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
15
|
's7722a91d3a512442': str`Last seen ${0}`,
|
|
16
16
|
's22965bb9808befb0': `Interrupt`,
|
|
17
|
+
's6dbbe2646b239ca5': `Closed`,
|
|
17
18
|
's122d4de68bcfcdf4': `It's okay to restart`,
|
|
18
19
|
's3eb2567092b4d7c1': `from the beginning`,
|
|
19
20
|
's28f37776b3901438': `It's okay to interrupt`,
|
package/src/locales/pt.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
15
|
's7722a91d3a512442': str`Last seen ${0}`,
|
|
16
16
|
's22965bb9808befb0': `Interrupt`,
|
|
17
|
+
's6dbbe2646b239ca5': `Closed`,
|
|
17
18
|
's122d4de68bcfcdf4': `It's okay to restart`,
|
|
18
19
|
's3eb2567092b4d7c1': `from the beginning`,
|
|
19
20
|
's28f37776b3901438': `It's okay to interrupt`,
|
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;
|