@nyaruka/temba-components 0.161.0 → 0.163.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.161.0",
3
+ "version": "0.163.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",
@@ -39,6 +39,7 @@
39
39
  "@jsplumb/browser-ui": "^6.2.10",
40
40
  "@lit/localize": "^0.12.2",
41
41
  "@open-wc/lit-helpers": "^0.7.0",
42
+ "centrifuge": "^5.7.0",
42
43
  "chart.js": "^4.5.1",
43
44
  "chartjs-adapter-luxon": "^1.3.1",
44
45
  "chartjs-plugin-datalabels": "^2.2.0",
@@ -68,6 +69,7 @@
68
69
  "@rollup/plugin-terser": "^0.4.4",
69
70
  "@rollup/plugin-typescript": "^12.3.0",
70
71
  "@types/leaflet": "1.4.4",
72
+ "@types/mocha": "^10.0.0",
71
73
  "@types/node": "22.13.0",
72
74
  "@types/remarkable": "^2.0.1",
73
75
  "@typescript-eslint/eslint-plugin": "^7.5.0",
@@ -107,7 +109,6 @@
107
109
  "sinon": "^21.0.0",
108
110
  "svgstore": "^3.0.1",
109
111
  "tslib": "2.8.1",
110
- "@types/mocha": "^10.0.0",
111
112
  "typescript": "5.9.3",
112
113
  "yargs": "^17.7.2"
113
114
  },
@@ -113,6 +113,13 @@ export interface MsgEvent extends ContactEvent {
113
113
  _logs_url?: string;
114
114
  }
115
115
 
116
+ // whether a message has nothing to show in its bubble (and isn't deleted)
117
+ const isEmptyMsg = (event: MsgEvent): boolean =>
118
+ !event._deleted &&
119
+ !!event.msg &&
120
+ !event.msg.text &&
121
+ (event.msg.attachments || []).length === 0;
122
+
116
123
  const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' } as any;
117
124
  const VERBOSE_FORMAT = {
118
125
  weekday: undefined,
@@ -351,13 +358,15 @@ export class Chat extends RapidElement {
351
358
  color: rgba(192, 24, 41, 0.6);
352
359
  }
353
360
 
354
- .deleted .bubble {
361
+ .deleted .bubble,
362
+ .empty .bubble {
355
363
  background: #fff;
356
364
  color: #999;
357
365
  border: 1px solid #e0e0e0;
358
366
  }
359
367
 
360
- .deleted .bubble .name {
368
+ .deleted .bubble .name,
369
+ .empty .bubble .name {
361
370
  color: #aaa;
362
371
  }
363
372
 
@@ -369,7 +378,8 @@ export class Chat extends RapidElement {
369
378
  font-size: 13px;
370
379
  }
371
380
 
372
- .message-deleted {
381
+ .message-deleted,
382
+ .message-empty {
373
383
  font-style: italic;
374
384
  margin-bottom: 0;
375
385
  line-height: 1.2;
@@ -838,6 +848,10 @@ export class Chat extends RapidElement {
838
848
  private msgMap = new Map<string, ContactEvent>();
839
849
  private metadataCache = new Map<string, ContactEvent>();
840
850
 
851
+ // bumped on reset so deferred addMessages work from before a reset is
852
+ // discarded rather than re-merged into the fresh view
853
+ private resetGeneration = 0;
854
+
841
855
  public firstUpdated(
842
856
  changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
843
857
  ): void {
@@ -858,10 +872,16 @@ export class Chat extends RapidElement {
858
872
  startTime = new Date();
859
873
  }
860
874
 
875
+ const generation = this.resetGeneration;
861
876
  const elapsed = new Date().getTime() - startTime.getTime();
862
877
  window.setTimeout(
863
878
  () => {
864
879
  this.fetching = false;
880
+
881
+ // the chat was reset while we were waiting, our messages are stale
882
+ if (generation !== this.resetGeneration) {
883
+ return;
884
+ }
865
885
  // first add messages to the map
866
886
  const newMessages = [];
867
887
  for (const m of messages) {
@@ -905,6 +925,10 @@ export class Chat extends RapidElement {
905
925
  }
906
926
 
907
927
  window.setTimeout(() => {
928
+ if (generation !== this.resetGeneration) {
929
+ return;
930
+ }
931
+
908
932
  // when appending (new messages at bottom), adjust scroll to maintain visible content
909
933
  // with column-reverse, new content at bottom increases scrollHeight
910
934
  if (append && (isScrolledAway || maintainScroll)) {
@@ -1311,13 +1335,14 @@ export class Chat extends RapidElement {
1311
1335
  (statusClass === 'failed' || statusClass === 'errored'));
1312
1336
  const unsendableClass = hasError ? 'error' : '';
1313
1337
  const deletedClass = msgEvent._deleted ? 'deleted' : '';
1338
+ const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
1314
1339
  const latestClass = index === msgIds.length - 1 ? 'latest' : '';
1315
1340
  const eventClass = msg._rendered ? 'is-event' : '';
1316
1341
  const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
1317
1342
  const matchClass =
1318
1343
  this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
1319
1344
  return html`<div
1320
- class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
1345
+ class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
1321
1346
  data-uuid=${msg.uuid || nothing}
1322
1347
  >
1323
1348
  ${this.renderMessage(
@@ -1416,6 +1441,9 @@ export class Chat extends RapidElement {
1416
1441
  : message._deleted.user?.name || 'user'
1417
1442
  : null;
1418
1443
 
1444
+ // messages with no text and no attachments get a muted placeholder
1445
+ const isEmpty = isEmptyMsg(message);
1446
+
1419
1447
  // check if message has location attachment and text is just coordinates
1420
1448
  const hasLocationAttachment = message.msg.attachments?.some((att) =>
1421
1449
  att.startsWith('geo:')
@@ -1466,12 +1494,17 @@ export class Chat extends RapidElement {
1466
1494
  Message deleted by ${deletedByText}
1467
1495
  </div>
1468
1496
  </div>`
1469
- : message.msg.text && !textIsCoordinates
1497
+ : isEmpty
1470
1498
  ? html`<div class="bubble">
1471
1499
  ${name ? html`<div class="name">${name}</div>` : null}
1472
- <div class="message-text">${messageText}</div>
1500
+ <div class="message-empty">Empty Message</div>
1473
1501
  </div>`
1474
- : null}
1502
+ : message.msg.text && !textIsCoordinates
1503
+ ? html`<div class="bubble">
1504
+ ${name ? html`<div class="name">${name}</div>` : null}
1505
+ <div class="message-text">${messageText}</div>
1506
+ </div>`
1507
+ : null}
1475
1508
 
1476
1509
  <div class="attachments">
1477
1510
  ${(message.msg.attachments || []).map(
@@ -1486,7 +1519,9 @@ export class Chat extends RapidElement {
1486
1519
  }
1487
1520
 
1488
1521
  public reset() {
1522
+ this.resetGeneration++;
1489
1523
  this.msgMap.clear();
1524
+ this.metadataCache.clear();
1490
1525
  this.messageGroups = [];
1491
1526
  this.hideBottomScroll = true;
1492
1527
  this.hideTopScroll = true;
@@ -116,6 +116,13 @@ export class TembaUser extends RapidElement {
116
116
  if (changed.has('avatar')) {
117
117
  if (this.avatar) {
118
118
  this.bgimage = `url('${this.avatar}') center / contain no-repeat`;
119
+ } else {
120
+ // clearing the avatar must also clear the image, otherwise a reused
121
+ // element keeps showing the previous user's avatar - fall back to the
122
+ // system default or the initials/contact-icon branch
123
+ this.bgimage = this.system
124
+ ? `url('${DEFAULT_AVATAR}') center / contain no-repeat`
125
+ : null;
119
126
  }
120
127
  }
121
128
  }
@@ -315,6 +315,29 @@ function getTypeName(
315
315
  return 'Unknown';
316
316
  }
317
317
 
318
+ /**
319
+ * Build the display fields for a result that matched on its type name
320
+ * (e.g. "email" matching a "Send Email" action). Shows the first content
321
+ * text as an unhighlighted preview when available, otherwise highlights
322
+ * the match within the type name itself. Returns null if the type name
323
+ * doesn't match the query.
324
+ */
325
+ function makeTypeNameResult(
326
+ typeName: string,
327
+ query: string,
328
+ contentTexts: string[]
329
+ ): Pick<SearchResult, 'fullText' | 'matchStart' | 'matchLength'> | null {
330
+ const idx = typeName.toLowerCase().indexOf(query);
331
+ if (idx === -1) {
332
+ return null;
333
+ }
334
+ const preview = contentTexts.find((t) => t.trim());
335
+ if (preview) {
336
+ return { fullText: preview, matchStart: 0, matchLength: 0 };
337
+ }
338
+ return { fullText: typeName, matchStart: idx, matchLength: query.length };
339
+ }
340
+
318
341
  /**
319
342
  * Get the localized name for a category, falling back to the original name.
320
343
  */
@@ -640,11 +663,13 @@ export class FlowSearch extends LitElement {
640
663
  if (node.actions) {
641
664
  for (const action of node.actions) {
642
665
  const actionConfig = ACTION_CONFIG[action.type];
666
+ const typeName = getTypeName(actionConfig, undefined);
643
667
  const searchAction = localizeAction(
644
668
  action,
645
669
  langLocalization?.[action.uuid]
646
670
  );
647
671
  const texts = getActionSearchTexts(searchAction);
672
+ let matched = false;
648
673
  for (const text of texts) {
649
674
  const lowerText = text.toLowerCase();
650
675
  const idx = lowerText.indexOf(query);
@@ -652,15 +677,31 @@ export class FlowSearch extends LitElement {
652
677
  results.push({
653
678
  nodeUuid: node.uuid,
654
679
  action,
655
- typeName: getTypeName(actionConfig, undefined),
680
+ typeName,
656
681
  color: getColor(actionConfig, undefined),
657
682
  fullText: text,
658
683
  matchStart: idx,
659
684
  matchLength: query.length
660
685
  });
686
+ matched = true;
661
687
  break; // One match per action is enough
662
688
  }
663
689
  }
690
+
691
+ // Fall back to matching the action type name (e.g. "email"
692
+ // matches all "Send Email" actions)
693
+ if (!matched) {
694
+ const typeMatch = makeTypeNameResult(typeName, query, texts);
695
+ if (typeMatch) {
696
+ results.push({
697
+ nodeUuid: node.uuid,
698
+ action,
699
+ typeName,
700
+ color: getColor(actionConfig, undefined),
701
+ ...typeMatch
702
+ });
703
+ }
704
+ }
664
705
  }
665
706
  }
666
707
  } else {
@@ -679,6 +720,8 @@ export class FlowSearch extends LitElement {
679
720
  }
680
721
  }
681
722
 
723
+ const typeName = getTypeName(undefined, nodeConfig);
724
+ let matched = false;
682
725
  for (const text of nodeTexts) {
683
726
  const lowerText = text.toLowerCase();
684
727
  const idx = lowerText.indexOf(query);
@@ -686,15 +729,31 @@ export class FlowSearch extends LitElement {
686
729
  results.push({
687
730
  nodeUuid: node.uuid,
688
731
  action: null,
689
- typeName: getTypeName(undefined, nodeConfig),
732
+ typeName,
690
733
  color: getColor(undefined, nodeConfig),
691
734
  fullText: text,
692
735
  matchStart: idx,
693
736
  matchLength: query.length
694
737
  });
738
+ matched = true;
695
739
  break; // One match per node is enough
696
740
  }
697
741
  }
742
+
743
+ // Fall back to matching the node type name (e.g. "wait" matches
744
+ // all "Wait for Response" nodes)
745
+ if (!matched) {
746
+ const typeMatch = makeTypeNameResult(typeName, query, nodeTexts);
747
+ if (typeMatch) {
748
+ results.push({
749
+ nodeUuid: node.uuid,
750
+ action: null,
751
+ typeName,
752
+ color: getColor(undefined, nodeConfig),
753
+ ...typeMatch
754
+ });
755
+ }
756
+ }
698
757
  }
699
758
  }
700
759
 
@@ -838,6 +897,11 @@ export class FlowSearch extends LitElement {
838
897
  // Replace newlines with spaces for single-line display
839
898
  const text = result.fullText.replace(/\n/g, ' ');
840
899
 
900
+ // Type-name matches show an unhighlighted content preview
901
+ if (matchLength === 0) {
902
+ return html`${text}`;
903
+ }
904
+
841
905
  // Show leading context before the match, then the match, then trailing.
842
906
  // CSS text-overflow:ellipsis clips the trailing text, so we keep the match
843
907
  // near the start. When the match is near the end of the text, show more
@@ -20,6 +20,7 @@ import {
20
20
  SPLIT_GROUP_METADATA
21
21
  } from './types';
22
22
  import { CustomEventType } from '../interfaces';
23
+ import { Dialog } from '../layout/Dialog';
23
24
  import { generateUUID } from '../utils';
24
25
  import {
25
26
  formatIssueMessage,
@@ -477,6 +478,11 @@ export class NodeEditor extends RapidElement {
477
478
  @state()
478
479
  private originalFormData: FormData = {};
479
480
 
481
+ // snapshot of getComparableFormData(), refreshed on any non-user mutation
482
+ // (init, async resolve) — the baseline for detecting unsaved edits when
483
+ // dismissing via escape
484
+ private pristineFormData = '';
485
+
480
486
  @state()
481
487
  private errors: { [key: string]: string } = {};
482
488
 
@@ -515,24 +521,112 @@ export class NodeEditor extends RapidElement {
515
521
  private issuesByAction!: Map<string, FlowIssue[]>;
516
522
 
517
523
  private boundHandleEscape = this.handleEscapeKey.bind(this);
524
+ private boundSwallowEscapeUp = this.swallowEscapeUp.bind(this);
518
525
 
519
526
  connectedCallback(): void {
520
527
  super.connectedCallback();
521
528
  this.initializeFormData();
522
529
  document.addEventListener('keydown', this.boundHandleEscape);
530
+ document.addEventListener('keyup', this.boundSwallowEscapeUp, true);
523
531
  }
524
532
 
525
533
  disconnectedCallback(): void {
526
534
  super.disconnectedCallback();
527
535
  document.removeEventListener('keydown', this.boundHandleEscape);
536
+ document.removeEventListener('keyup', this.boundSwallowEscapeUp, true);
528
537
  }
529
538
 
530
539
  private handleEscapeKey(event: KeyboardEvent): void {
531
540
  if (event.key === 'Escape' && this.isOpen) {
541
+ const path = event.composedPath();
542
+
543
+ // an escape aimed at an open select dropdown just closes the dropdown
544
+ if (
545
+ path.some(
546
+ (el) =>
547
+ el instanceof Element &&
548
+ el.tagName === 'TEMBA-SELECT' &&
549
+ (el as any).isOpen?.()
550
+ )
551
+ ) {
552
+ return;
553
+ }
554
+
555
+ if (!this.ownsEscape(path)) {
556
+ return;
557
+ }
558
+
559
+ if (
560
+ this.hasUnsavedChanges() &&
561
+ !window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
562
+ ) {
563
+ return;
564
+ }
532
565
  this.handleCancel();
533
566
  }
534
567
  }
535
568
 
569
+ // an escape routed through a dialog other than our own (e.g. a nested
570
+ // dialog or modax opened from a field) belongs to that dialog; an escape
571
+ // with no dialog in its path (canvas or body focus) is ours to handle
572
+ private ownsEscape(path: EventTarget[]): boolean {
573
+ for (const el of path) {
574
+ if (el instanceof Element && el.tagName === 'TEMBA-DIALOG') {
575
+ return el === this.shadowRoot?.querySelector('temba-dialog');
576
+ }
577
+ }
578
+ return true;
579
+ }
580
+
581
+ // temba-dialog handles escape itself on KEYUP reaching its container, which
582
+ // would prompt a second time after the keydown handler above has already
583
+ // asked (or dismiss unprompted if its keyup ever arrived first). While the
584
+ // editor is open, escape within our own dialog is owned entirely by the
585
+ // keydown handler, so stop those keyups before the dialog sees them —
586
+ // but leave escapes belonging to a dialog layered above us alone.
587
+ private swallowEscapeUp(event: KeyboardEvent): void {
588
+ if (
589
+ event.key === 'Escape' &&
590
+ this.isOpen &&
591
+ this.ownsEscape(event.composedPath())
592
+ ) {
593
+ event.stopPropagation();
594
+ }
595
+ }
596
+
597
+ private hasUnsavedChanges(): boolean {
598
+ return this.getComparableFormData() !== this.pristineFormData;
599
+ }
600
+
601
+ // JSON of formData with array-editor scaffolding filtered out: array editors
602
+ // seed a blank row (carrying field defaults, e.g. an attachment type) into
603
+ // form data on first render, which must not count as a user edit
604
+ private getComparableFormData(): string {
605
+ const form = this.getConfig()?.form;
606
+ const comparable = { ...this.formData };
607
+
608
+ if (form) {
609
+ Object.entries(form).forEach(([fieldName, fieldConfig]) => {
610
+ const value = comparable[fieldName];
611
+ if (fieldConfig.type === 'array' && Array.isArray(value)) {
612
+ // same emptiness semantics as ArrayEditor.isEmptyItem
613
+ const isEmpty =
614
+ fieldConfig.isEmptyItem ||
615
+ ((item: any) => {
616
+ const values = Object.values(item || {});
617
+ return (
618
+ values.length === 0 ||
619
+ values.every((v) => v === undefined || v === null || v === '')
620
+ );
621
+ });
622
+ comparable[fieldName] = value.filter((item: any) => !isEmpty(item));
623
+ }
624
+ });
625
+ }
626
+
627
+ return JSON.stringify(comparable);
628
+ }
629
+
536
630
  willUpdate(changedProperties: PropertyValues): void {
537
631
  super.willUpdate(changedProperties);
538
632
  if (
@@ -719,14 +813,24 @@ export class NodeEditor extends RapidElement {
719
813
  this.formData[key] = { ...value };
720
814
  }
721
815
  });
816
+
817
+ // fresh form, so nothing to warn about discarding yet
818
+ this.pristineFormData = this.getComparableFormData();
722
819
  }
723
820
 
724
821
  private async resolveFormData(): Promise<void> {
725
822
  const config = this.getConfig();
726
823
  if (!config?.resolveFormData) return;
727
824
 
825
+ const input = this.formData;
728
826
  try {
729
- const resolved = await config.resolveFormData(this.formData);
827
+ const resolved = await config.resolveFormData(input);
828
+
829
+ // if the user edited while the resolve was in flight, applying the
830
+ // stale result would overwrite their edit — and rebasing the baseline
831
+ // below would then mask the loss as a clean form
832
+ if (this.formData !== input) return;
833
+
730
834
  if (resolved && resolved !== this.formData) {
731
835
  this.formData = resolved;
732
836
  this.processFormDataForEditing();
@@ -749,6 +853,9 @@ export class NodeEditor extends RapidElement {
749
853
  });
750
854
  }
751
855
 
856
+ // resolving isn't a user edit; rebase the unsaved-changes baseline
857
+ this.pristineFormData = this.getComparableFormData();
858
+
752
859
  this.requestUpdate();
753
860
  }
754
861
  } catch (error) {
@@ -2690,6 +2797,7 @@ export class NodeEditor extends RapidElement {
2690
2797
  .open="${this.isOpen}"
2691
2798
  .originX=${this.dialogOrigin?.x ?? null}
2692
2799
  .originY=${this.dialogOrigin?.y ?? null}
2800
+ .checkForChanges=${() => this.hasUnsavedChanges()}
2693
2801
  @temba-button-clicked=${this.handleDialogButtonClick}
2694
2802
  primaryButtonName="Save"
2695
2803
  cancelButtonName="Cancel"
@@ -20,6 +20,9 @@ export class DialogButton {
20
20
  }
21
21
 
22
22
  export class Dialog extends ResizeElement {
23
+ public static readonly UNSAVED_CHANGES_MESSAGE =
24
+ 'You have unsaved changes. Are you sure you want to discard them?';
25
+
23
26
  static get widths(): { [size: string]: string } {
24
27
  return {
25
28
  small: '400px',
@@ -273,12 +276,29 @@ export class Dialog extends ResizeElement {
273
276
  @property({ attribute: false })
274
277
  onButtonClicked: (button: Button) => void;
275
278
 
279
+ // when set, consulted instead of the built-in edit tracking to decide
280
+ // whether escape should confirm before dismissing — for owners that can
281
+ // compare actual values (and so ignore edits that were reverted)
282
+ @property({ attribute: false })
283
+ checkForChanges?: () => boolean;
284
+
276
285
  @property({ type: Number })
277
286
  originX: number | null = null;
278
287
 
279
288
  @property({ type: Number })
280
289
  originY: number | null = null;
281
290
 
291
+ // whether any input/change has bubbled from our content since opening;
292
+ // the default signal that escape would discard the user's edits
293
+ private contentEdited = false;
294
+
295
+ // elements the user has actually interacted with (pointer or key) since the
296
+ // dialog opened; components fire synthetic change events on programmatic
297
+ // value changes (initialization of preset values, async option resolution),
298
+ // so an input/change only counts as an edit when it originates from an
299
+ // element the user has touched
300
+ private interactedElements = new WeakSet<EventTarget>();
301
+
282
302
  scrollOffset: any = 0;
283
303
 
284
304
  public constructor() {
@@ -322,6 +342,8 @@ export class Dialog extends ResizeElement {
322
342
  super.updated(changes);
323
343
 
324
344
  if (changes.has('open')) {
345
+ this.contentEdited = false;
346
+ this.interactedElements = new WeakSet<EventTarget>();
325
347
  const body = document.querySelector('body');
326
348
 
327
349
  if (this.open) {
@@ -457,8 +479,33 @@ export class Dialog extends ResizeElement {
457
479
  return this.shadowRoot.querySelector(`temba-button[primary]`);
458
480
  }
459
481
 
482
+ public hasUnsavedChanges(): boolean {
483
+ return this.checkForChanges ? this.checkForChanges() : this.contentEdited;
484
+ }
485
+
486
+ private handleContentInteraction(event: Event) {
487
+ // the path covers the interacted element and its ancestors, so a change
488
+ // fired later by a containing component (e.g. a select whose inner input
489
+ // was clicked) still matches
490
+ event.composedPath().forEach((el) => this.interactedElements.add(el));
491
+ }
492
+
493
+ private handleContentEdited(event: Event) {
494
+ // only the originating element is checked (not its ancestors, which the
495
+ // paths of unrelated interactions also pass through)
496
+ if (this.interactedElements.has(event.composedPath()[0])) {
497
+ this.contentEdited = true;
498
+ }
499
+ }
500
+
460
501
  private handleKeyUp(event: KeyboardEvent) {
461
502
  if (event.key === 'Escape') {
503
+ if (
504
+ this.hasUnsavedChanges() &&
505
+ !window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
506
+ ) {
507
+ return;
508
+ }
462
509
  this.clickCancel();
463
510
  }
464
511
  }
@@ -533,6 +580,10 @@ export class Dialog extends ResizeElement {
533
580
  }"></div>
534
581
  <div
535
582
  @keyup=${this.handleKeyUp}
583
+ @keydown=${this.handleContentInteraction}
584
+ @pointerdown=${this.handleContentInteraction}
585
+ @input=${this.handleContentEdited}
586
+ @change=${this.handleContentEdited}
536
587
  style=${styleMap(dialogStyle)}
537
588
  class="dialog-container"
538
589
  >