@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.
@@ -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
- if (prevItem) {
315
- const newItem = newItems[this.cursorIndex];
316
- const prevValue = this.getValue(prevItem);
317
- if (prevValue !== this.getValue(newItem)) {
318
- const newIndex = newItems.findIndex(
319
- (option) => this.getValue(option) === prevValue
320
- );
321
- this.cursorIndex = newIndex;
322
-
323
- // make sure our focused item is visible
324
- window.setTimeout(() => {
325
- const options = this.shadowRoot.querySelector('temba-options');
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
- }, 0);
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).then((page: ResultsPage) => {
461
- this.sanitizeResults(page.results).then((sanitizedResults) => {
462
- if (this.sanitizeOption) {
463
- sanitizedResults.forEach(this.sanitizeOption);
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
- this.items = [...this.items, ...sanitizedResults];
467
- this.nextPage = page.next;
468
- this.pages++;
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)}
@@ -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
- if (this.items.length > 0) {
13
- const lastActivity = this.items[0].ticket.last_activity_on;
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
- this.renderOption = (contact: Contact): TemplateResult => {
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.ticket.closed_on
53
- ? null
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
- : null}
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
- ${!contact.ticket.closed_on && contact.ticket.assignee
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.closed_on ||
105
- contact.ticket.last_activity_on}
174
+ value=${contact.ticket.last_activity_on}
106
175
  display="duration"
107
176
  ></temba-date>
108
177
  </div>
@@ -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;
@@ -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`,
@@ -630,6 +630,7 @@ export class Simulator extends RapidElement {
630
630
  }
631
631
  .message-input input {
632
632
  flex: 1;
633
+ min-width: 0;
633
634
  border: 1px solid #c6c6c857;
634
635
  border-radius: 20px;
635
636
  padding: 8px 15px;
@@ -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: TypedObjectRef[];
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;