@nyaruka/temba-components 0.164.0 → 0.165.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +1555 -484
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/ProgressBar.ts +15 -2
  19. package/src/form/Compose.ts +31 -1
  20. package/src/form/ContactSearch.ts +229 -126
  21. package/src/interfaces.ts +52 -0
  22. package/src/layout/Card.ts +25 -0
  23. package/src/layout/CardLayout.ts +3 -3
  24. package/src/layout/HeaderBar.ts +4 -1
  25. package/src/list/BroadcastList.ts +912 -0
  26. package/src/list/ContentList.ts +46 -10
  27. package/src/list/FieldList.ts +1057 -0
  28. package/src/list/SortableList.ts +7 -2
  29. package/src/list/TembaList.ts +8 -0
  30. package/src/list/TembaMenu.ts +5 -38
  31. package/src/live/CampaignEvents.ts +33 -19
  32. package/src/live/ContactChat.ts +7 -1
  33. package/src/live/ContactDetails.ts +20 -13
  34. package/src/live/ContactFieldEditor.ts +7 -3
  35. package/src/live/ContactStoreElement.ts +3 -1
  36. package/src/live/ContactTimeline.ts +28 -7
  37. package/src/locales/es.ts +4 -0
  38. package/src/locales/fr.ts +4 -0
  39. package/src/locales/pt.ts +4 -0
  40. package/static/svg/index.svg +1 -1
  41. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  42. package/static/svg/work/traced/star-filled.svg +1 -0
  43. package/static/svg/work/used/star-filled.svg +3 -0
  44. package/stress-test.js +3 -3
  45. package/temba-modules.ts +4 -0
  46. package/web-test-runner.config.mjs +1 -1
  47. package/xliff/es.xlf +12 -0
  48. package/xliff/fr.xlf +12 -0
  49. package/xliff/pt.xlf +12 -0
@@ -92,6 +92,7 @@ export class SortableList extends RapidElement {
92
92
 
93
93
  draggingIdx = -1;
94
94
  draggingEle = null;
95
+ ghostOriginalDisplay = '';
95
96
  dropPlaceholder: HTMLDivElement = null;
96
97
  pendingDropIndex = -1;
97
98
  pendingTargetElement: HTMLElement = null;
@@ -729,8 +730,11 @@ export class SortableList extends RapidElement {
729
730
  this.isExternalDrag = true;
730
731
  this.hideDropPlaceholder();
731
732
 
732
- // hide the ghost element when dragging externally
733
+ // hide the ghost element when dragging externally, remembering its
734
+ // display (inlined from the original's computed style) so re-entry
735
+ // can restore it - forcing 'block' would break flex-laid-out ghosts
733
736
  if (this.ghostElement) {
737
+ this.ghostOriginalDisplay = this.ghostElement.style.display;
734
738
  this.ghostElement.style.display = 'none';
735
739
  }
736
740
 
@@ -745,7 +749,8 @@ export class SortableList extends RapidElement {
745
749
 
746
750
  // show the ghost element again when dragging internally
747
751
  if (this.ghostElement) {
748
- this.ghostElement.style.display = 'block';
752
+ this.ghostElement.style.display =
753
+ this.ghostOriginalDisplay || 'block';
749
754
  }
750
755
 
751
756
  this.fireCustomEvent(CustomEventType.DragInternal, {
@@ -220,6 +220,14 @@ export class TembaList extends RapidElement {
220
220
  return this.selected;
221
221
  }
222
222
 
223
+ /** Deselects without firing a change — e.g. mobile going back to the
224
+ * list, so the same row can be selected again. */
225
+ public clearSelection() {
226
+ this.cursorIndex = -1;
227
+ this.selected = null;
228
+ this.value = null;
229
+ }
230
+
223
231
  public refresh(): void {
224
232
  this.refreshKey = 'requested_' + new Date().getTime();
225
233
  }
@@ -33,7 +33,6 @@ export interface MenuItem {
33
33
  avatar?: string;
34
34
  trigger?: boolean;
35
35
  event?: string;
36
- mobile?: boolean;
37
36
  initial?: string;
38
37
  }
39
38
 
@@ -401,29 +400,13 @@ export class TembaMenu extends ResizeElement {
401
400
  flex-direction: row;
402
401
  }
403
402
 
404
- .root.fully-collapsed.mobile .level.level-0 > .item {
403
+ /* when collapsed on mobile, the only thing in the header is the
404
+ hamburger — any new direct child of level-0 must opt in with
405
+ the .top class to stay visible here */
406
+ .root.fully-collapsed.mobile .level.level-0 > *:not(.top) {
405
407
  display: none;
406
408
  }
407
409
 
408
- .root.fully-collapsed.mobile .level.level-0 > .empty {
409
- display: block;
410
- width: 100%;
411
- min-width: inherit;
412
- max-width: inherit;
413
- }
414
-
415
- .root .level.level-0 > .show-mobile {
416
- display: none;
417
- }
418
-
419
- .root.mobile .level.level-0 > .show-mobile {
420
- display: flex;
421
- }
422
-
423
- .root.fully-collapsed.mobile .level.level-0 > .show-mobile {
424
- display: contents !important;
425
- }
426
-
427
410
  .root.fully-collapsed.mobile .level.level-0 .expand-icon {
428
411
  max-height: 100%;
429
412
  padding: 1em;
@@ -455,27 +438,12 @@ export class TembaMenu extends ResizeElement {
455
438
  min-width: inherit;
456
439
  }
457
440
 
458
- .mobile.fully-collapsed .item {
459
- }
460
-
461
441
  .mobile .expand-icon {
462
442
  transition: none;
463
443
  transform: rotate(-90deg);
464
444
  align-self: center;
465
445
  }
466
446
 
467
- .mobile.fully-collapsed .level-0 .empty {
468
- flex-grow: 1;
469
- }
470
-
471
- .mobile.fully-collapsed .top-spacer {
472
- flex-grow: 0;
473
- }
474
-
475
- .mobile.fully-collapsed #dd-workspace {
476
- display: none;
477
- }
478
-
479
447
  .mobile.fully-collapsed .expand-icon {
480
448
  transform: none;
481
449
  }
@@ -1203,8 +1171,7 @@ export class TembaMenu extends ResizeElement {
1203
1171
  expanding: this.expanding && this.expanding === menuItem.id,
1204
1172
  expanded: this.isExpanded(menuItem),
1205
1173
  iconless: !icon && !collapsedIcon && !menuItem.avatar,
1206
- pressed: this.pressedItem && this.pressedItem.id == menuItem.id,
1207
- 'show-mobile': menuItem.mobile
1174
+ pressed: this.pressedItem && this.pressedItem.id == menuItem.id
1208
1175
  });
1209
1176
 
1210
1177
  if (menuItem.avatar) {
@@ -94,9 +94,6 @@ export class CampaignEvents extends EndpointMonitorElement {
94
94
  @property({ type: String })
95
95
  lang_scheduled_contacts = 'Contacts scheduled for this event';
96
96
 
97
- @property({ type: String })
98
- lang_okay = 'Okay';
99
-
100
97
  @property({ type: String })
101
98
  lang_send_message = 'Send Message';
102
99
 
@@ -494,14 +491,28 @@ export class CampaignEvents extends EndpointMonitorElement {
494
491
  line-height: 1.5;
495
492
  }
496
493
 
497
- /* our own footer so the Okay button follows the same horizontal
498
- padding as the header - its right edge lines up with Delete. A
499
- full-bleed rule mirrors the header's. */
500
- .detail-footer {
501
- display: flex;
502
- justify-content: flex-end;
503
- padding: 1em;
504
- border-top: 1px solid var(--border, #e4e7ec);
494
+ /* the close (✕) in the header's upper right - the square
495
+ hover-wash button treatment (the pager buttons'), sitting
496
+ just outboard of the action buttons */
497
+ .detail-close {
498
+ flex-shrink: 0;
499
+ margin-left: -4px;
500
+ display: inline-flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ width: 28px;
504
+ height: 28px;
505
+ border-radius: var(--r-sm);
506
+ color: var(--text-3, #7b8593);
507
+ cursor: pointer;
508
+ user-select: none;
509
+ }
510
+ .detail-close:hover {
511
+ background: var(--sunken);
512
+ color: var(--text-1);
513
+ }
514
+ .detail-close temba-icon {
515
+ --icon-color: currentColor;
505
516
  }
506
517
 
507
518
  .detail-section-title {
@@ -1017,6 +1028,17 @@ export class CampaignEvents extends EndpointMonitorElement {
1017
1028
  : null}
1018
1029
  </div>`
1019
1030
  : null}
1031
+ <div
1032
+ class="detail-close"
1033
+ role="button"
1034
+ tabindex="0"
1035
+ aria-label="Close"
1036
+ @click=${this.handleDetailClosed}
1037
+ @keydown=${(e: KeyboardEvent) =>
1038
+ this.handleActivationKey(e, this.handleDetailClosed)}
1039
+ >
1040
+ <temba-icon name=${Icon.close} size="1.4"></temba-icon>
1041
+ </div>
1020
1042
  </div>
1021
1043
 
1022
1044
  <div class="detail-body">
@@ -1072,14 +1094,6 @@ export class CampaignEvents extends EndpointMonitorElement {
1072
1094
 
1073
1095
  ${this.renderFires()}
1074
1096
  </div>
1075
-
1076
- <div class="detail-footer">
1077
- <temba-button
1078
- primary
1079
- name=${this.lang_okay}
1080
- @click=${this.handleDetailClosed}
1081
- ></temba-button>
1082
- </div>
1083
1097
  </div>
1084
1098
  `
1085
1099
  : null}
@@ -605,7 +605,13 @@ export class ContactChat extends ContactStoreElement {
605
605
  const channels = new Set<string>();
606
606
  if (this.isConnected && this.currentContact) {
607
607
  channels.add(`history:${this.currentContact.uuid}`);
608
- if (this.currentTicket) {
608
+
609
+ // on a contact switch the new ticket is set before the new contact has
610
+ // finished fetching, so hold off on the ticket channel until they match
611
+ // - a ticket paired with another contact's channel is denied server-side
612
+ const contactPending =
613
+ this.contact && this.contact !== this.currentContact.uuid;
614
+ if (this.currentTicket && !contactPending) {
609
615
  channels.add(
610
616
  `history:${this.currentContact.uuid}:${this.currentTicket.uuid}`
611
617
  );
@@ -38,7 +38,7 @@ export class ContactDetails extends ContactStoreElement {
38
38
 
39
39
  .row .label {
40
40
  color: var(--text-2);
41
- font-size: 12px;
41
+ font-size: 11px;
42
42
  font-weight: var(--w-medium);
43
43
  margin-top: 0.25em;
44
44
  margin-left: 0.25em;
@@ -46,7 +46,7 @@ export class ContactDetails extends ContactStoreElement {
46
46
 
47
47
  .row .value {
48
48
  margin-left: 0.25em;
49
- margin-top: 0.1em;
49
+ margin-top: 0.35em;
50
50
  min-height: 1.75em;
51
51
  display: flex;
52
52
  flex-wrap: wrap;
@@ -55,6 +55,12 @@ export class ContactDetails extends ContactStoreElement {
55
55
 
56
56
  .group {
57
57
  }
58
+
59
+ /* the last row needs no separator — the card edge closes it off */
60
+ temba-contact-field.last-field {
61
+ --contact-field-separator: none;
62
+ margin-bottom: 0;
63
+ }
58
64
  `;
59
65
  }
60
66
 
@@ -70,6 +76,17 @@ export class ContactDetails extends ContactStoreElement {
70
76
 
71
77
  return html`
72
78
  <div class="wrapper">
79
+ ${this.data.urns.map((urn) => {
80
+ let scheme = SCHEMES[urn.scheme];
81
+ if (!scheme) {
82
+ scheme = capitalize(urn.scheme as any);
83
+ }
84
+ return html`<temba-contact-field
85
+ name=${scheme}
86
+ value=${urn.display || urn.path}
87
+ disabled
88
+ ></temba-contact-field>`;
89
+ })}
73
90
  ${this.data.groups.length > 0
74
91
  ? html` <div class="row">
75
92
  <div class="label">Groups</div>
@@ -89,17 +106,6 @@ export class ContactDetails extends ContactStoreElement {
89
106
  </div>
90
107
  </div>`
91
108
  : null}
92
- ${this.data.urns.map((urn) => {
93
- let scheme = SCHEMES[urn.scheme];
94
- if (!scheme) {
95
- scheme = capitalize(urn.scheme as any);
96
- }
97
- return html`<temba-contact-field
98
- name=${scheme}
99
- value=${urn.display || urn.path}
100
- disabled
101
- ></temba-contact-field>`;
102
- })}
103
109
  ${this.data.ref
104
110
  ? html`<temba-contact-field
105
111
  name="Ref"
@@ -128,6 +134,7 @@ export class ContactDetails extends ContactStoreElement {
128
134
  disabled
129
135
  ></temba-contact-field>
130
136
  <temba-contact-field
137
+ class="last-field"
131
138
  name="Last Seen"
132
139
  value=${this.data.last_seen_on}
133
140
  type="datetime"
@@ -61,7 +61,7 @@ export class ContactFieldEditor extends RapidElement {
61
61
  --color-widget-bg-focused: #fff;
62
62
  --widget-box-shadow: none;
63
63
  padding-bottom: 0.6em;
64
- border-bottom: 1px solid #ececec;
64
+ border-bottom: var(--contact-field-separator, 1px solid #ececec);
65
65
  }
66
66
 
67
67
  .wrapper.disabled {
@@ -108,9 +108,12 @@ export class ContactFieldEditor extends RapidElement {
108
108
  text-overflow: ellipsis;
109
109
  }
110
110
 
111
+ /* read-only rows set the label clearly apart from the value — the
112
+ same quiet label treatment as .field-label on the editable rows,
113
+ a touch smaller, over a full-color value */
111
114
  .label .name {
112
115
  color: var(--text-2);
113
- font-size: 12px;
116
+ font-size: 11px;
114
117
  font-weight: var(--w-medium);
115
118
  }
116
119
 
@@ -120,8 +123,9 @@ export class ContactFieldEditor extends RapidElement {
120
123
  }
121
124
 
122
125
  .disabled .value {
126
+ color: var(--text-1);
123
127
  margin-left: 0.25em;
124
- margin-top: 0.1em;
128
+ margin-top: 0.35em;
125
129
  min-height: 1.75em;
126
130
  }
127
131
 
@@ -66,7 +66,8 @@ export class ContactStoreElement extends EndpointMonitorElement {
66
66
  }
67
67
 
68
68
  public willUpdate(changed: PropertyValues): void {
69
- super.willUpdate(changed);
69
+ // derive our url before the base class runs so it sees the url change
70
+ // in this same pass (clearing stale data when the contact is unset)
70
71
  if (changed.has('contact') || changed.has('endpoint')) {
71
72
  if (this.contact) {
72
73
  this.url = `${this.endpoint}${this.contact}`;
@@ -74,5 +75,6 @@ export class ContactStoreElement extends EndpointMonitorElement {
74
75
  this.url = null;
75
76
  }
76
77
  }
78
+ super.willUpdate(changed);
77
79
  }
78
80
  }
@@ -115,7 +115,9 @@ export class ContactTimeline extends EndpointMonitorElement {
115
115
  flex-direction: column;
116
116
  align-items: center;
117
117
  text-align: center;
118
- padding: 7em 1em 4em;
118
+ /* a host card sets --empty-padding to compact this treatment —
119
+ the default suits a full-height tab pane */
120
+ padding: var(--empty-padding, 7em 1em 4em);
119
121
  color: var(--text-color);
120
122
  }
121
123
 
@@ -124,9 +126,17 @@ export class ContactTimeline extends EndpointMonitorElement {
124
126
  --icon-color: var(--text-3, #7b8593);
125
127
  }
126
128
 
129
+ /* the icon / blurb / link around the title — a host card hides
130
+ these so the empty card is just the stylized message */
131
+ .empty-extras {
132
+ display: var(--empty-extras-display, contents);
133
+ }
134
+
127
135
  .empty-title {
128
136
  font-weight: 600;
129
137
  margin-bottom: 0.4em;
138
+ font-size: var(--empty-title-size, inherit);
139
+ color: var(--empty-title-color, inherit);
130
140
  }
131
141
 
132
142
  .empty-help {
@@ -547,7 +557,14 @@ export class ContactTimeline extends EndpointMonitorElement {
547
557
  : Array.isArray(this.data.future)
548
558
  ? this.data.future.length
549
559
  : 0;
550
- this.fireCustomEvent(CustomEventType.DetailsChanged, { count });
560
+ // empty mirrors the render()'s empty-state condition so a host
561
+ // card can drop its chrome entirely when there's nothing to show
562
+ const empty =
563
+ count === 0 &&
564
+ !(Array.isArray(this.data.campaigns) && this.data.campaigns.length) &&
565
+ !(Array.isArray(this.data.future) && this.data.future.length) &&
566
+ !(Array.isArray(this.data.past) && this.data.past.length);
567
+ this.fireCustomEvent(CustomEventType.DetailsChanged, { count, empty });
551
568
  }
552
569
  }
553
570
  }
@@ -810,12 +827,16 @@ export class ContactTimeline extends EndpointMonitorElement {
810
827
  ) {
811
828
  return html`<div class="empty">
812
829
  <slot name="empty">
813
- <temba-icon name=${Icon.schedule} size="2"></temba-icon>
830
+ <div class="empty-extras">
831
+ <temba-icon name=${Icon.schedule} size="2"></temba-icon>
832
+ </div>
814
833
  <div class="empty-title">${this.lang_empty}</div>
815
- <div class="empty-help">${this.lang_empty_help}</div>
816
- <a class="empty-link" href="/campaign/" onclick="goto(event, this)"
817
- >${this.lang_campaigns_link}</a
818
- >
834
+ <div class="empty-extras">
835
+ <div class="empty-help">${this.lang_empty_help}</div>
836
+ <a class="empty-link" href="/campaign/" onclick="goto(event, this)"
837
+ >${this.lang_campaigns_link}</a
838
+ >
839
+ </div>
819
840
  </slot>
820
841
  </div>`;
821
842
  }
package/src/locales/es.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  's73b4d70c02f4b4e0': `No options`,
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's81f17cfc89a04338': `Interrupt flow`,
16
+ 's122d4de68bcfcdf4': `It's okay to restart`,
17
+ 's3eb2567092b4d7c1': `from the beginning`,
18
+ 's28f37776b3901438': `It's okay to interrupt`,
19
+ 's6838d20b10f7512a': `and start this one`,
16
20
  's8f02e3a18ffc083a': `Are not currently in a flow`,
17
21
  's638236250662c6b3': `Have sent a message in the last`,
18
22
  's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
package/src/locales/fr.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  'scf1453991c986b25': `Tab to complete, enter to select`,
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's81f17cfc89a04338': `Interrupt flow`,
16
+ 's122d4de68bcfcdf4': `It's okay to restart`,
17
+ 's3eb2567092b4d7c1': `from the beginning`,
18
+ 's28f37776b3901438': `It's okay to interrupt`,
19
+ 's6838d20b10f7512a': `and start this one`,
16
20
  's8f02e3a18ffc083a': `Are not currently in a flow`,
17
21
  's638236250662c6b3': `Have sent a message in the last`,
18
22
  's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
package/src/locales/pt.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  'scf1453991c986b25': `Tab to complete, enter to select`,
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's81f17cfc89a04338': `Interrupt flow`,
16
+ 's122d4de68bcfcdf4': `It's okay to restart`,
17
+ 's3eb2567092b4d7c1': `from the beginning`,
18
+ 's28f37776b3901438': `It's okay to interrupt`,
19
+ 's6838d20b10f7512a': `and start this one`,
16
20
  's8f02e3a18ffc083a': `Are not currently in a flow`,
17
21
  's638236250662c6b3': `Have sent a message in the last`,
18
22
  's4788ee206c4570c7': `Have not started this flow in the last 90 days`,