@nyaruka/temba-components 0.164.0 → 0.166.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 (55) hide show
  1. package/CHANGELOG.md +20 -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 +1983 -865
  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/Chat.ts +232 -18
  19. package/src/display/ProgressBar.ts +15 -2
  20. package/src/form/Compose.ts +31 -1
  21. package/src/form/ContactSearch.ts +229 -126
  22. package/src/interfaces.ts +73 -0
  23. package/src/layout/Card.ts +25 -0
  24. package/src/layout/CardLayout.ts +3 -3
  25. package/src/layout/HeaderBar.ts +4 -1
  26. package/src/list/BroadcastList.ts +912 -0
  27. package/src/list/ContentList.ts +46 -10
  28. package/src/list/FieldList.ts +1057 -0
  29. package/src/list/NotificationList.ts +131 -21
  30. package/src/list/SortableList.ts +7 -2
  31. package/src/list/TembaList.ts +19 -5
  32. package/src/list/TembaMenu.ts +47 -46
  33. package/src/live/CampaignEvents.ts +33 -19
  34. package/src/live/ContactChat.ts +190 -18
  35. package/src/live/ContactDetails.ts +20 -13
  36. package/src/live/ContactFieldEditor.ts +7 -3
  37. package/src/live/ContactStoreElement.ts +3 -1
  38. package/src/live/ContactTimeline.ts +28 -7
  39. package/src/live/Realtime.ts +133 -0
  40. package/src/live/SocketService.ts +24 -0
  41. package/src/locales/es.ts +4 -0
  42. package/src/locales/fr.ts +4 -0
  43. package/src/locales/pt.ts +4 -0
  44. package/src/store/Store.ts +12 -0
  45. package/src/utils.ts +4 -0
  46. package/static/svg/index.svg +1 -1
  47. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  48. package/static/svg/work/traced/star-filled.svg +1 -0
  49. package/static/svg/work/used/star-filled.svg +3 -0
  50. package/stress-test.js +3 -3
  51. package/temba-modules.ts +4 -0
  52. package/web-test-runner.config.mjs +1 -1
  53. package/xliff/es.xlf +12 -0
  54. package/xliff/fr.xlf +12 -0
  55. package/xliff/pt.xlf +12 -0
@@ -1,31 +1,28 @@
1
- import { css, html, TemplateResult } from 'lit';
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
2
  import { TembaList } from './TembaList';
3
3
  import { Options } from '../display/Options';
4
4
  import { Icon } from '../Icons';
5
-
6
- interface Notification {
7
- created_on: string;
8
- type: string;
9
- url: string;
10
- is_seen: boolean;
11
- export?: {
12
- type: string;
13
- num_records: number;
14
- };
15
- import?: {
16
- type: string;
17
- num_records: number;
18
- };
19
- incident?: {
20
- type: string;
21
- started_on: string;
22
- ended_on?: string;
23
- };
24
- }
5
+ import { CustomEventType, Notification } from '../interfaces';
6
+ import {
7
+ RealtimeSubscription,
8
+ subscribeToNotifications
9
+ } from '../live/Realtime';
10
+ import { deleteRequest } from '../utils';
25
11
 
26
12
  export class NotificationList extends TembaList {
27
13
  reverseRefresh = false;
28
14
  internalFocusDisabled = true;
15
+
16
+ // fed by socket publications instead of interval polling
17
+ protected pollingEnabled = false;
18
+
19
+ private realtimeSubscription: RealtimeSubscription = null;
20
+
21
+ // publications that arrived before the initial fetch completed - a direct
22
+ // prepend would be clobbered when the fetch lands, so they wait for it
23
+ private pendingPubs: Notification[] = [];
24
+ private fetchedOnce = false;
25
+ private subscribedOnce = false;
29
26
  static get styles() {
30
27
  return css`
31
28
  :host {
@@ -134,6 +131,119 @@ export class NotificationList extends TembaList {
134
131
  };
135
132
  }
136
133
 
134
+ public connectedCallback() {
135
+ super.connectedCallback();
136
+ this.realtimeSubscription = subscribeToNotifications(
137
+ (notification) => this.handleNotification(notification),
138
+ () => this.handleSubscribed()
139
+ );
140
+ }
141
+
142
+ public disconnectedCallback() {
143
+ super.disconnectedCallback();
144
+ if (this.realtimeSubscription) {
145
+ this.realtimeSubscription.unsubscribe();
146
+ this.realtimeSubscription = null;
147
+ }
148
+ }
149
+
150
+ public willUpdate(changed: PropertyValues): void {
151
+ super.willUpdate(changed);
152
+ if (changed.has('loading') && !this.loading) {
153
+ this.fetchedOnce = true;
154
+ const pending = this.pendingPubs;
155
+ this.pendingPubs = [];
156
+ pending.forEach((notification) => this.prependNotification(notification));
157
+ }
158
+ }
159
+
160
+ private handleNotification(notification: Notification) {
161
+ // ignore redeliveries of notifications we already have so e.g. the
162
+ // menu's unseen badge only lights for genuinely new ones
163
+ if (
164
+ this.items.some((item) => item.url === notification.url) ||
165
+ this.pendingPubs.some((item) => item.url === notification.url)
166
+ ) {
167
+ return;
168
+ }
169
+
170
+ // announce arrival, even mid-fetch - e.g. the menu's unseen badge
171
+ this.fireCustomEvent(CustomEventType.NotificationReceived, {
172
+ notification
173
+ });
174
+
175
+ if (this.loading || !this.fetchedOnce) {
176
+ this.pendingPubs.push(notification);
177
+ } else {
178
+ this.prependNotification(notification);
179
+ }
180
+ }
181
+
182
+ private prependNotification(notification: Notification) {
183
+ // dedupe by url (our valueKey) and prepend
184
+ this.items = [
185
+ notification,
186
+ ...this.items.filter((item) => item.url !== notification.url)
187
+ ];
188
+ this.mostRecentItem = notification;
189
+ }
190
+
191
+ /**
192
+ * Fires on every (re)subscribe including after reconnects. The initial
193
+ * endpoint fetch covers the first one regardless of which completes first,
194
+ * so only refetch page one on resubscribes, to catch anything missed
195
+ * while offline.
196
+ */
197
+ private handleSubscribed() {
198
+ if (this.subscribedOnce && this.fetchedOnce) {
199
+ this.refresh();
200
+ }
201
+ this.subscribedOnce = true;
202
+ }
203
+
204
+ // urls marked seen on the server but still shown bold for this viewing
205
+ private seenUrls = new Set<string>();
206
+
207
+ /**
208
+ * Marks everything currently unseen as seen on the server, without any
209
+ * refetching - the socket keeps our items current and seen state is fully
210
+ * known here. Items stay bold for the current viewing and unbold on the
211
+ * next call (e.g. the next popup open). Resolves false if the server
212
+ * didn't record it, in which case nothing advances and the next call
213
+ * retries.
214
+ */
215
+ public markSeen(): Promise<boolean> {
216
+ // unbold whatever was marked seen last time
217
+ if (this.seenUrls.size > 0) {
218
+ this.items = this.items.map((item) =>
219
+ this.seenUrls.has(item.url) ? { ...item, is_seen: true } : item
220
+ );
221
+ }
222
+
223
+ const unseen = this.items.filter((item) => !item.is_seen);
224
+ if (unseen.length === 0 || !this.endpoint) {
225
+ return Promise.resolve(true);
226
+ }
227
+
228
+ // only advance our seen state if the server actually recorded it,
229
+ // otherwise we'd unbold items the server still considers unseen
230
+ return deleteRequest(this.endpoint)
231
+ .then((response) => {
232
+ if (!response.ok) {
233
+ console.warn(
234
+ `failed marking notifications seen (${response.status})`
235
+ );
236
+ return false;
237
+ }
238
+ this.seenUrls = new Set(unseen.map((item) => item.url));
239
+ return true;
240
+ })
241
+ .catch((error) => {
242
+ console.warn('failed marking notifications seen', error);
243
+ return false;
244
+ });
245
+ }
246
+
137
247
  public renderHeader(): TemplateResult {
138
248
  return html`<div class="header">
139
249
  <temba-icon name="notification"></temba-icon>
@@ -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, {
@@ -68,6 +68,9 @@ export class TembaList extends RapidElement {
68
68
 
69
69
  reverseRefresh = true;
70
70
 
71
+ // subclasses that get realtime updates can opt out of interval polling
72
+ protected pollingEnabled = true;
73
+
71
74
  // our next page from our endpoint
72
75
  nextPage: string = null;
73
76
 
@@ -108,14 +111,17 @@ export class TembaList extends RapidElement {
108
111
 
109
112
  public connectedCallback() {
110
113
  super.connectedCallback();
111
- this.refreshInterval = setInterval(() => {
112
- if (!this.paused) {
113
- this.refreshKey = 'default_' + new Date().getTime();
114
- }
115
- }, DEFAULT_REFRESH);
114
+ if (this.pollingEnabled) {
115
+ this.refreshInterval = setInterval(() => {
116
+ if (!this.paused) {
117
+ this.refreshKey = 'default_' + new Date().getTime();
118
+ }
119
+ }, DEFAULT_REFRESH);
120
+ }
116
121
  }
117
122
 
118
123
  public disconnectedCallback() {
124
+ super.disconnectedCallback();
119
125
  clearInterval(this.refreshInterval);
120
126
  }
121
127
 
@@ -220,6 +226,14 @@ export class TembaList extends RapidElement {
220
226
  return this.selected;
221
227
  }
222
228
 
229
+ /** Deselects without firing a change — e.g. mobile going back to the
230
+ * list, so the same row can be selected again. */
231
+ public clearSelection() {
232
+ this.cursorIndex = -1;
233
+ this.selected = null;
234
+ this.value = null;
235
+ }
236
+
223
237
  public refresh(): void {
224
238
  this.refreshKey = 'requested_' + new Date().getTime();
225
239
  }
@@ -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
  }
@@ -819,17 +787,40 @@ export class TembaMenu extends ResizeElement {
819
787
  }
820
788
 
821
789
  this.loadItems(item);
822
-
823
- // refresh any embedded lists
824
- this.shadowRoot
825
- .querySelectorAll('temba-notification-list')
826
- .forEach((ele: NotificationList) => {
827
- ele.refresh();
828
- });
829
790
  }
830
791
 
831
792
  public refresh = debounce(this.doRefresh, 200);
832
793
 
794
+ /**
795
+ * Opening a popup with notifications in it marks them all seen - the badge
796
+ * clears now, the items unbold next open. No fetching; the socket keeps
797
+ * the list itself current.
798
+ */
799
+ private handlePopupOpened(event: CustomEvent, menuItem: MenuItem) {
800
+ const dropdown = event.currentTarget as HTMLElement;
801
+ const lists = dropdown.querySelectorAll('temba-notification-list');
802
+ if (lists.length === 0) {
803
+ return;
804
+ }
805
+
806
+ // clear the badge optimistically, restoring it if marking seen fails so
807
+ // the unseen indicator isn't silently lost
808
+ const bubble = menuItem.bubble;
809
+ if (bubble) {
810
+ menuItem.bubble = null;
811
+ this.requestUpdate('root');
812
+ }
813
+
814
+ lists.forEach((list: NotificationList) => {
815
+ list.markSeen().then((marked) => {
816
+ if (!marked && bubble && !menuItem.bubble) {
817
+ menuItem.bubble = bubble;
818
+ this.requestUpdate('root');
819
+ }
820
+ });
821
+ });
822
+ }
823
+
833
824
  // eslint-disable-next-line @typescript-eslint/no-empty-function
834
825
  private loadItems(item: MenuItem, event: MouseEvent = null) {
835
826
  if (item && item.endpoint) {
@@ -1143,6 +1134,13 @@ export class TembaMenu extends ResizeElement {
1143
1134
  if (menuItem.type === 'temba-notification-list') {
1144
1135
  return html`<temba-notification-list
1145
1136
  endpoint=${menuItem.href}
1137
+ @temba-notification=${() => {
1138
+ // light the unseen badge on our popup item as they arrive
1139
+ if (parent && parent.bubble !== 'tomato') {
1140
+ parent.bubble = 'tomato';
1141
+ this.requestUpdate('root');
1142
+ }
1143
+ }}
1146
1144
  ></temba-notification-list>`;
1147
1145
  }
1148
1146
 
@@ -1203,8 +1201,7 @@ export class TembaMenu extends ResizeElement {
1203
1201
  expanding: this.expanding && this.expanding === menuItem.id,
1204
1202
  expanded: this.isExpanded(menuItem),
1205
1203
  iconless: !icon && !collapsedIcon && !menuItem.avatar,
1206
- pressed: this.pressedItem && this.pressedItem.id == menuItem.id,
1207
- 'show-mobile': menuItem.mobile
1204
+ pressed: this.pressedItem && this.pressedItem.id == menuItem.id
1208
1205
  });
1209
1206
 
1210
1207
  if (menuItem.avatar) {
@@ -1287,7 +1284,11 @@ export class TembaMenu extends ResizeElement {
1287
1284
 
1288
1285
  if (menuItem.popup) {
1289
1286
  return html`
1290
- <temba-dropdown id="dd-${menuItem.id}">
1287
+ <temba-dropdown
1288
+ id="dd-${menuItem.id}"
1289
+ @temba-opened=${(event: CustomEvent) =>
1290
+ this.handlePopupOpened(event, menuItem)}
1291
+ >
1291
1292
  <div slot="toggle">${item}</div>
1292
1293
 
1293
1294
  <div slot="dropdown">
@@ -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}