@nyaruka/temba-components 0.169.0 → 0.170.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.169.0",
3
+ "version": "0.170.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -2,7 +2,7 @@ import { TemplateResult, html, nothing, PropertyValueMap, css } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { repeat } from 'lit/directives/repeat.js';
4
4
  import { RapidElement } from '../RapidElement';
5
- import { CustomEventType } from '../interfaces';
5
+ import { CustomEventType, QuickReply } from '../interfaces';
6
6
  import { TicketEvent } from '../events';
7
7
  import { renderEventSummary } from '../events/eventRenderers';
8
8
  import { DEFAULT_AVATAR } from '../webchat/assets';
@@ -81,7 +81,7 @@ interface User extends ObjectReference {
81
81
  export interface Msg {
82
82
  text: string;
83
83
  channel: ObjectReference;
84
- quick_replies: string[];
84
+ quick_replies: (string | QuickReply)[];
85
85
  urn: string;
86
86
  direction: string;
87
87
  type: string;
@@ -9,9 +9,6 @@ import { styleMap } from 'lit-html/directives/style-map.js';
9
9
  export class Dropdown extends RapidElement {
10
10
  static get styles() {
11
11
  return css`
12
- :host {
13
- }
14
-
15
12
  .wrapper {
16
13
  position: relative;
17
14
  }
@@ -25,9 +22,6 @@ export class Dropdown extends RapidElement {
25
22
  overflow: auto;
26
23
  }
27
24
 
28
- .dropdown:focus {
29
- }
30
-
31
25
  .dropdown.dormant {
32
26
  height: 0;
33
27
  overflow: hidden;
@@ -35,7 +29,16 @@ export class Dropdown extends RapidElement {
35
29
 
36
30
  .dropdown {
37
31
  position: fixed;
38
- z-index: 2;
32
+ /* open popups sit above floating windows (simulator, 5000) and
33
+ other page chrome, but below dialogs and toasts (10000);
34
+ temba-content-menu mirrors this value on its temba-dropdown
35
+ flex item (see ContentMenu.ts) */
36
+ z-index: 9000;
37
+ /* the dormant (height: 0) rule is applied 250ms after close, so
38
+ until then this stays a laid-out, invisible fixed box at
39
+ z-index 9000 over whatever it was covering — ignore pointer
40
+ events unless we're actually open */
41
+ pointer-events: none;
39
42
  padding: 0;
40
43
  opacity: 0;
41
44
  border-radius: calc(var(--curvature) * 1.5);
@@ -67,6 +70,7 @@ export class Dropdown extends RapidElement {
67
70
 
68
71
  .open .dropdown {
69
72
  opacity: 1;
73
+ pointer-events: auto;
70
74
  transform: translateY(0.5em) scale(1);
71
75
  }
72
76
 
@@ -338,6 +338,13 @@ export class Options extends RapidElement {
338
338
  @property({ attribute: false })
339
339
  renderOption: { (option: any, selected: boolean): TemplateResult };
340
340
 
341
+ // renders between rows (and above the first) when the data crosses a
342
+ // grouping boundary - the result sits outside the option rows so it never
343
+ // picks up their hover or selection treatment. Returning null means the
344
+ // rows aren't a boundary and nothing is rendered between them.
345
+ @property({ attribute: false })
346
+ renderDivider: { (prev: any, option: any): TemplateResult | null };
347
+
341
348
  @property({ attribute: false })
342
349
  renderOptionName: { (option: any, selected: boolean): TemplateResult };
343
350
 
@@ -816,20 +823,26 @@ export class Options extends RapidElement {
816
823
  <div class="${classesInner}" style=${styleMap(optionsStyle)}>
817
824
  ${options.length > 0
818
825
  ? options.map((option, index) => {
819
- return html`<div
820
- data-option-index="${index}"
821
- @mousemove=${this.handleMouseMove}
822
- @mousedown=${this.handleOptionClick}
823
- class="option ${index === this.cursorIndex &&
824
- !this.internalFocusDisabled
825
- ? 'focused'
826
- : ''}"
827
- >
828
- ${this.resolvedRenderOption(
829
- option,
830
- index === this.cursorIndex
831
- )}
832
- </div>`;
826
+ return html`${this.renderDivider
827
+ ? this.renderDivider(
828
+ index > 0 ? options[index - 1] : null,
829
+ option
830
+ )
831
+ : null}
832
+ <div
833
+ data-option-index="${index}"
834
+ @mousemove=${this.handleMouseMove}
835
+ @mousedown=${this.handleOptionClick}
836
+ class="option ${index === this.cursorIndex &&
837
+ !this.internalFocusDisabled
838
+ ? 'focused'
839
+ : ''}"
840
+ >
841
+ ${this.resolvedRenderOption(
842
+ option,
843
+ index === this.cursorIndex
844
+ )}
845
+ </div>`;
833
846
  })
834
847
  : this.visible && this.showEmptyMessage
835
848
  ? html`<div
@@ -1,4 +1,4 @@
1
- import { css, html, PropertyValues, TemplateResult } from 'lit';
1
+ import { css, html, nothing, PropertyValues, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import { Store } from '../store/Store';
@@ -47,7 +47,14 @@ export class TembaDate extends RapidElement {
47
47
  }
48
48
  }
49
49
 
50
- public render(): TemplateResult {
50
+ public render(): TemplateResult | typeof nothing {
51
+ // unparseable values and zero-value times (e.g. go's zero time,
52
+ // 0001-01-01T00:00:00Z, for a contact that has never been seen) aren't
53
+ // real dates - render nothing rather than something like "2025 years ago"
54
+ if (this.datetime && !(this.datetime.isValid && this.datetime.year > 1)) {
55
+ return nothing;
56
+ }
57
+
51
58
  if (this.datetime && this.store) {
52
59
  this.datetime.setLocale(this.store.getLocale());
53
60
 
@@ -1,7 +1,13 @@
1
1
  import { TemplateResult, html, css, PropertyValues, nothing } from 'lit';
2
2
  import { FieldElement } from './FieldElement';
3
3
  import { property } from 'lit/decorators.js';
4
- import { Attachment, CustomEventType, Language, Shortcut } from '../interfaces';
4
+ import {
5
+ Attachment,
6
+ CustomEventType,
7
+ Language,
8
+ QuickReply,
9
+ Shortcut
10
+ } from '../interfaces';
5
11
  import { Icon } from '../Icons';
6
12
  import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
7
13
  import { Select } from './select/Select';
@@ -12,7 +18,7 @@ import { setCaretOffset } from '../excellent/caret-utils';
12
18
  export interface ComposeValue {
13
19
  text: string;
14
20
  attachments: { uuid: string }[];
15
- quick_replies: string[];
21
+ quick_replies: (string | QuickReply)[];
16
22
  optin: string;
17
23
  template: string;
18
24
  variables: string[];
@@ -236,7 +242,7 @@ export class Compose extends FieldElement {
236
242
  [lang: string]: {
237
243
  text: string;
238
244
  attachments: Attachment[];
239
- quick_replies: string[];
245
+ quick_replies: (string | QuickReply)[];
240
246
  optin?: { name: string; uuid: string };
241
247
  template?: string;
242
248
  variables?: string[];
@@ -357,11 +363,12 @@ export class Compose extends FieldElement {
357
363
  this.currentText = langValue.text || '';
358
364
  this.initialText = langValue.text || '';
359
365
  this.currentAttachments = langValue.attachments || [];
360
- this.currentQuickReplies = (langValue.quick_replies || []).map(
361
- (value) => {
362
- return { name: value, value };
363
- }
364
- );
366
+ this.currentQuickReplies = (langValue.quick_replies || [])
367
+ .map((reply) =>
368
+ typeof reply === 'string' ? reply : (reply.text ?? null)
369
+ )
370
+ .filter((reply): reply is string => reply !== null)
371
+ .map((value) => ({ name: value, value }));
365
372
  this.currentOptin = langValue['optin'] ? [langValue['optin']] : [];
366
373
  }
367
374
 
@@ -410,25 +417,14 @@ export class Compose extends FieldElement {
410
417
  // own keydown inserts a newline (which would flash before the send
411
418
  // clears it); bubble keeps Enter-to-send working from anywhere else
412
419
  // in the compose after inner widgets have had their shot at it
413
- this.addEventListener(
414
- 'keydown',
415
- this.handleHostKeyDown as EventListener,
416
- true
417
- );
418
- this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
420
+ this.addEventListener('keydown', this.handleHostKeyDownCapture, true);
421
+ this.addEventListener('keydown', this.handleHostKeyDownBubble);
419
422
  }
420
423
 
421
424
  disconnectedCallback() {
422
425
  super.disconnectedCallback();
423
- this.removeEventListener(
424
- 'keydown',
425
- this.handleHostKeyDown as EventListener,
426
- true
427
- );
428
- this.removeEventListener(
429
- 'keydown',
430
- this.handleHostKeyDown as EventListener
431
- );
426
+ this.removeEventListener('keydown', this.handleHostKeyDownCapture, true);
427
+ this.removeEventListener('keydown', this.handleHostKeyDownBubble);
432
428
  }
433
429
 
434
430
  private handleShortcutIconClick() {
@@ -454,7 +450,18 @@ export class Compose extends FieldElement {
454
450
  });
455
451
  }
456
452
 
457
- private handleHostKeyDown = (evt: KeyboardEvent) => {
453
+ // events from inside our shadow tree are retargeted to the host, so
454
+ // both registrations see eventPhase as AT_TARGET — the phase can't
455
+ // tell us which registration is firing, so each binds it explicitly
456
+ private handleHostKeyDownCapture = (evt: KeyboardEvent) => {
457
+ this.handleHostKeyDown(evt, true);
458
+ };
459
+
460
+ private handleHostKeyDownBubble = (evt: KeyboardEvent) => {
461
+ this.handleHostKeyDown(evt, false);
462
+ };
463
+
464
+ private handleHostKeyDown = (evt: KeyboardEvent, capture: boolean) => {
458
465
  if (evt.key === 'Escape' && this.showShortcuts) {
459
466
  evt.preventDefault();
460
467
  evt.stopPropagation();
@@ -474,10 +481,12 @@ export class Compose extends FieldElement {
474
481
  // during capture only editor presses are claimed — anything else
475
482
  // (quick replies, attachments) waits for the bubble so the widget
476
483
  // it targets keeps first claim on the event
477
- if (
478
- evt.eventPhase === Event.CAPTURING_PHASE &&
479
- !(editor && evt.composedPath().includes(editor))
480
- ) {
484
+ if (capture && !(editor && evt.composedPath().includes(editor))) {
485
+ return;
486
+ }
487
+ // by the bubble an inner widget may have claimed the Enter for
488
+ // itself (e.g. the quick reply select adding a tag) — not a send
489
+ if (!capture && evt.defaultPrevented) {
481
490
  return;
482
491
  }
483
492
  if (editor) {
package/src/interfaces.ts CHANGED
@@ -47,6 +47,14 @@ export interface Attachment {
47
47
  error: string;
48
48
  }
49
49
 
50
+ /** A quick reply as stored/served by the server (engine shape) —
51
+ * `text` buttons carry text, `location` requests may omit it. */
52
+ export interface QuickReply {
53
+ type?: string;
54
+ text?: string;
55
+ extra?: string;
56
+ }
57
+
50
58
  export enum DateStyle {
51
59
  DayFirst = 'day_first',
52
60
  MonthFirst = 'month_first',
@@ -231,8 +239,9 @@ export interface Broadcast {
231
239
  /** Base-language attachments (`{content_type, url}` objects or
232
240
  * `contentType:url` strings). */
233
241
  attachments?: (string | Attachment)[];
234
- /** Base-language quick replies. */
235
- quick_replies?: string[];
242
+ /** Base-language quick replies — engine `{text}` objects, with
243
+ * plain strings tolerated for older data. */
244
+ quick_replies?: (string | QuickReply)[];
236
245
  /** The opt-in the broadcast requests, when it is one. */
237
246
  optin?: ObjectReference | null;
238
247
  /** The WhatsApp template the broadcast sends, when it uses one. */
@@ -271,7 +280,7 @@ export interface Msg {
271
280
  text: string;
272
281
  status: string;
273
282
  channel: ObjectReference;
274
- quick_replies: string[];
283
+ quick_replies: (string | QuickReply)[];
275
284
  urn: string;
276
285
  /** The message's contact — populated by the messages CRUDL
277
286
  * endpoint. Carries whichever of name/urn the endpoint exposes. */
@@ -479,6 +488,7 @@ export enum CustomEventType {
479
488
  StoreUpdated = 'temba-store-updated',
480
489
  Ready = 'temba-ready',
481
490
  OrderChanged = 'temba-order-changed',
491
+ ColumnOrderChanged = 'temba-column-order-changed',
482
492
  DragStart = 'temba-drag-start',
483
493
  DragStop = 'temba-drag-stop',
484
494
  DragExternal = 'temba-drag-external',
@@ -872,7 +872,9 @@ export class BroadcastList extends ContentList<Broadcast> {
872
872
  ${broadcast.quick_replies.map(
873
873
  (reply) =>
874
874
  html`<temba-label type="neutral"
875
- >${reply}</temba-label
875
+ >${typeof reply === 'string'
876
+ ? reply
877
+ : (reply.text ?? reply.type)}</temba-label
876
878
  >`
877
879
  )}
878
880
  </div>`
@@ -3,13 +3,19 @@ import { property, state } from 'lit/decorators.js';
3
3
  import { ContentList, ContentListColumn } from './ContentList';
4
4
  import { Icon } from '../Icons';
5
5
  import { Contact } from '../interfaces';
6
- import { getUrl } from '../utils';
6
+ import { getUrl, postJSON } from '../utils';
7
+ import { getStore } from '../store/Store';
7
8
 
8
9
  const FIELD_PREFIX = 'field:';
9
10
 
10
11
  /** Placeholder shown in any cell whose value is empty. */
11
12
  const EMPTY = '--';
12
13
 
14
+ /** Ceiling on how many pages of fields we'll walk. The API serves 250
15
+ * per page, so this is far past any real workspace — it exists only so
16
+ * an endpoint that keeps handing back a `next` can't spin forever. */
17
+ const MAX_FIELD_PAGES = 20;
18
+
13
19
  /**
14
20
  * Contact CRUDL list — drop-in replacement for the rapidpro
15
21
  * `contacts/contact_list.html` table. Each row carries a contact
@@ -22,6 +28,11 @@ const EMPTY = '--';
22
28
  * {@link ContactList.fieldsEndpoint} on connect; cells read each
23
29
  * contact's value out of `item.fields[<key>]`. Date/time fields
24
30
  * render as a relative duration, matching the Last-seen column.
31
+ *
32
+ * When {@link ContactList.priorityEndpoint} is set the field columns
33
+ * can be dragged into a new order, which is saved as the workspace's
34
+ * featured-field order (the list renders featured fields by priority,
35
+ * so column order and field order are the same thing).
25
36
  */
26
37
  export class ContactList extends ContentList<Contact> {
27
38
  static get styles() {
@@ -45,10 +56,25 @@ export class ContactList extends ContentList<Contact> {
45
56
  }
46
57
 
47
58
  /** Endpoint returning `{ results: ContactField[] }`. Fields where
48
- * `featured: true` become extra columns. */
59
+ * `featured: true` become extra columns.
60
+ *
61
+ * The list deliberately fetches fields itself instead of reading the
62
+ * global store: it has to work standalone (and against a custom
63
+ * endpoint), and it may mount before any store exists on the page.
64
+ * The store, when present, is only *told* about changes — see the
65
+ * refresh after a priority save. The endpoint has no featured filter,
66
+ * so the fetch pages through all fields and filters client-side. */
49
67
  @property({ type: String, attribute: 'fields-endpoint' })
50
68
  fieldsEndpoint = '/api/v2/fields.json';
51
69
 
70
+ /** Endpoint accepting `{ featured: [keys...] }` to save featured-field
71
+ * order (the same one the fields management page posts to). When set,
72
+ * the field columns become drag-reorderable and a drop saves the new
73
+ * order org-wide; the host only sets it for users holding the
74
+ * update-priority permission. */
75
+ @property({ type: String, attribute: 'priority-endpoint' })
76
+ priorityEndpoint = '';
77
+
52
78
  /** Anonymous workspaces mask URN values, so instead of the URN
53
79
  * column the list shows each contact's ref (served as its own
54
80
  * anon-only key by the endpoint). */
@@ -104,7 +130,7 @@ export class ContactList extends ContentList<Contact> {
104
130
  // Rebuilding here (rather than in updated()) means a mounted
105
131
  // anon attribute is reflected in the first paint instead of
106
132
  // flashing the URN header for a frame.
107
- if (changes.has('anon')) {
133
+ if (changes.has('anon') || changes.has('priorityEndpoint')) {
108
134
  this.columns = this.buildColumns();
109
135
  }
110
136
  }
@@ -127,11 +153,27 @@ export class ContactList extends ContentList<Contact> {
127
153
  const controller = new AbortController();
128
154
  this.pendingFieldsController = controller;
129
155
  try {
130
- const response = await getUrl(this.fieldsEndpoint, controller);
131
- // If the controller has been swapped or cleared, the response
132
- // is from a stale request drop it on the floor.
133
- if (this.pendingFieldsController !== controller) return;
134
- const all = response.json?.results || [];
156
+ // The fields API is cursor paginated (250 per page), and the
157
+ // featured list a drop posts back is authoritative any featured
158
+ // field we never read would be silently un-featured org-wide. So
159
+ // walk every page before deciding what's featured. Nothing is
160
+ // assigned until the whole walk lands, so a page that fails leaves
161
+ // the previous (complete) list in place rather than a truncated one.
162
+ const all: any[] = [];
163
+ const fetched = new Set<string>();
164
+ let url: string = this.fieldsEndpoint;
165
+ for (let page = 0; url && page < MAX_FIELD_PAGES; page++) {
166
+ // an endpoint whose `next` points back at a page we've already
167
+ // read would otherwise loop until the page cap
168
+ if (fetched.has(url)) break;
169
+ fetched.add(url);
170
+ const response = await getUrl(url, controller);
171
+ // If the controller has been swapped or cleared, the response
172
+ // is from a stale request — drop it on the floor.
173
+ if (this.pendingFieldsController !== controller) return;
174
+ all.push(...(response.json?.results || []));
175
+ url = response.json?.next || '';
176
+ }
135
177
  this.featuredFields = all
136
178
  .filter((f: any) => f.featured)
137
179
  .sort((a: any, b: any) => (b.priority ?? 0) - (a.priority ?? 0));
@@ -173,7 +215,11 @@ export class ContactList extends ContentList<Contact> {
173
215
  sortable: true,
174
216
  resizeMinWidth: '40px',
175
217
  maxWidth: '200px',
176
- resizable: true
218
+ resizable: true,
219
+ // Field columns can be dragged into a new order, but only when
220
+ // the host wired up somewhere to save it — reordering that
221
+ // silently reverts on reload would read as broken.
222
+ reorderable: !!this.priorityEndpoint
177
223
  })
178
224
  );
179
225
  // Name + URN are the pinned identity columns and are not
@@ -224,6 +270,64 @@ export class ContactList extends ContentList<Contact> {
224
270
  ];
225
271
  }
226
272
 
273
+ /** A committed header drag reordered the field columns — keep
274
+ * featuredFields aligned with the new column order (so any rebuild
275
+ * preserves it) and save it as the workspace's featured-field order.
276
+ * Column order maps directly onto priority: leftmost = highest, the
277
+ * same contract as the fields management page, whose endpoint this
278
+ * posts to with the full featured list. */
279
+ protected onColumnOrderChanged(columns: ContentListColumn[]): void {
280
+ // A fields fetch still in flight was started against the old order
281
+ // and would land on top of the drop, snapping the columns back with
282
+ // the new order already on its way to the server.
283
+ if (this.pendingFieldsController) {
284
+ this.pendingFieldsController.abort();
285
+ this.pendingFieldsController = undefined;
286
+ }
287
+ const keys = columns
288
+ .filter((c) => c.key.startsWith(FIELD_PREFIX))
289
+ .map((c) => c.key.substring(FIELD_PREFIX.length));
290
+ this.featuredFields = keys
291
+ .map((key) => (this.featuredFields || []).find((f: any) => f.key === key))
292
+ .filter(Boolean);
293
+ if (!this.priorityEndpoint) return;
294
+ postJSON(this.priorityEndpoint, { featured: keys })
295
+ .then((response) => {
296
+ // postUrl only rejects on 5xx, so a 400/403/404 arrives here as a
297
+ // perfectly resolved promise — the order was never stored and we
298
+ // have to treat it as the failure it is.
299
+ if (response.status < 200 || response.status >= 300) {
300
+ throw response;
301
+ }
302
+ })
303
+ .catch((error) => {
304
+ console.warn('failed to save featured field order', error);
305
+ // The list went away while the save was in flight — there are no
306
+ // columns left to put back, and refetching would only leave a
307
+ // request outstanding against a detached element.
308
+ if (!this.isConnected) return;
309
+ // Nothing made the dragged order durable, so stop showing it —
310
+ // refetch and fall back on whatever the server still holds. The
311
+ // success path deliberately doesn't refetch: we already display
312
+ // exactly what we just saved, and a read-replica answering with
313
+ // pre-write data would yank the columns back for no reason.
314
+ this.loadFields();
315
+ })
316
+ .finally(() => {
317
+ // Featured fields are workspace state other components on the
318
+ // page read from the store (contact details, the fields page),
319
+ // so its cached copy has to hear about the new order too. Looked
320
+ // up here rather than cached on connect so a list that mounted
321
+ // ahead of the store still finds it.
322
+ getStore()
323
+ ?.refreshFields()
324
+ .catch(() => {
325
+ // a stale store cache is cosmetic and elsewhere; the list
326
+ // itself is already showing the saved order
327
+ });
328
+ });
329
+ }
330
+
227
331
  protected getRowIcon(_item: Contact): string | null {
228
332
  return Icon.contact;
229
333
  }