@nyaruka/temba-components 0.163.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 (66) hide show
  1. package/CHANGELOG.md +27 -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 +5266 -3031
  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 +3 -2
  19. package/src/display/Lightbox.ts +57 -109
  20. package/src/display/ProgressBar.ts +15 -2
  21. package/src/display/Thumbnail.ts +34 -7
  22. package/src/form/Compose.ts +31 -1
  23. package/src/form/ContactSearch.ts +229 -126
  24. package/src/interfaces.ts +131 -2
  25. package/src/layout/Card.ts +336 -0
  26. package/src/layout/CardLayout.ts +425 -0
  27. package/src/layout/CardStack.ts +131 -0
  28. package/src/layout/Dialog.ts +33 -24
  29. package/src/layout/HeaderBar.ts +41 -0
  30. package/src/layout/PageHeader.ts +5 -6
  31. package/src/layout/Resizer.ts +20 -0
  32. package/src/list/BroadcastList.ts +912 -0
  33. package/src/list/CampaignList.ts +144 -0
  34. package/src/list/ContactList.ts +3 -1
  35. package/src/list/ContentList.ts +46 -10
  36. package/src/list/FieldList.ts +1057 -0
  37. package/src/list/FlowList.ts +31 -7
  38. package/src/list/MsgList.ts +18 -12
  39. package/src/list/SortableList.ts +52 -8
  40. package/src/list/TembaList.ts +8 -0
  41. package/src/list/TembaMenu.ts +5 -38
  42. package/src/list/TriggerList.ts +538 -0
  43. package/src/live/CampaignEvents.ts +1108 -0
  44. package/src/live/ContactChat.ts +7 -1
  45. package/src/live/ContactDetails.ts +20 -14
  46. package/src/live/ContactFieldEditor.ts +7 -3
  47. package/src/live/ContactFields.ts +1 -1
  48. package/src/live/ContactNameFetch.ts +5 -2
  49. package/src/live/ContactNotepad.ts +88 -2
  50. package/src/live/ContactStoreElement.ts +15 -3
  51. package/src/live/ContactTimeline.ts +28 -7
  52. package/src/locales/es.ts +4 -0
  53. package/src/locales/fr.ts +4 -0
  54. package/src/locales/pt.ts +4 -0
  55. package/src/utils.ts +19 -0
  56. package/static/svg/index.svg +1 -1
  57. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  58. package/static/svg/work/traced/star-filled.svg +1 -0
  59. package/static/svg/work/used/star-filled.svg +3 -0
  60. package/stress-test.js +3 -3
  61. package/temba-modules.ts +18 -0
  62. package/web-dev-server.config.mjs +54 -0
  63. package/web-test-runner.config.mjs +1 -1
  64. package/xliff/es.xlf +12 -0
  65. package/xliff/fr.xlf +12 -0
  66. package/xliff/pt.xlf +12 -0
@@ -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,18 +106,6 @@ export class ContactDetails extends ContactStoreElement {
89
106
  </div>
90
107
  </div>`
91
108
  : null}
92
- ${this.data.urns.map((urn) => {
93
- const parts = urn.split(':');
94
- let scheme = SCHEMES[parts[0]];
95
- if (!scheme) {
96
- scheme = capitalize(parts[0] as any);
97
- }
98
- return html`<temba-contact-field
99
- name=${scheme}
100
- value=${parts[1]}
101
- disabled
102
- ></temba-contact-field>`;
103
- })}
104
109
  ${this.data.ref
105
110
  ? html`<temba-contact-field
106
111
  name="Ref"
@@ -129,6 +134,7 @@ export class ContactDetails extends ContactStoreElement {
129
134
  disabled
130
135
  ></temba-contact-field>
131
136
  <temba-contact-field
137
+ class="last-field"
132
138
  name="Last Seen"
133
139
  value=${this.data.last_seen_on}
134
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
 
@@ -151,7 +151,7 @@ export class ContactFields extends ContactStoreElement {
151
151
  const value = field.value;
152
152
 
153
153
  // TODO: Use contact.postChanges instead of postJSON
154
- postJSON('/api/v2/contacts.json?uuid=' + this.data.uuid, {
154
+ postJSON(this.endpoint + this.data.uuid, {
155
155
  fields: { [field.key]: value }
156
156
  })
157
157
  .then((response: any) => {
@@ -1,6 +1,6 @@
1
1
  import { css, html, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
- import { ContactStoreElement } from './ContactStoreElement';
3
+ import { ContactStoreElement, getDestinationURN } from './ContactStoreElement';
4
4
 
5
5
  export class ContactNameFetch extends ContactStoreElement {
6
6
  @property({ type: Number, attribute: 'icon-size' })
@@ -21,9 +21,12 @@ export class ContactNameFetch extends ContactStoreElement {
21
21
 
22
22
  public render(): TemplateResult {
23
23
  if (this.data) {
24
+ // show the URN that will be used when messaging the contact, falling
25
+ // back to their highest priority URN if none are sendable
26
+ const urn = getDestinationURN(this.data) || this.data.urns[0] || null;
24
27
  return html` <temba-contact-name
25
28
  name=${this.data.name || this.data.ref}
26
- urn=${this.data.urns.length > 0 ? this.data.urns[0] : null}
29
+ urn=${urn ? `${urn.scheme}:${urn.display || urn.path}` : null}
27
30
  ></temba-contact-name>
28
31
  <slot></slot>`;
29
32
  }
@@ -13,6 +13,11 @@ export class ContactNotepad extends ContactStoreElement {
13
13
  dirtyMessage =
14
14
  'You have unsaved changes to the contact notepad. Are you sure you want to contiunue?';
15
15
 
16
+ // grow with the note instead of filling the parent and scrolling —
17
+ // used when embedded in a card that must never scroll internally
18
+ @property({ type: Boolean, reflect: true })
19
+ autogrow = false;
20
+
16
21
  static get styles() {
17
22
  return css`
18
23
  ${designTokens}
@@ -52,6 +57,41 @@ export class ContactNotepad extends ContactStoreElement {
52
57
  outline: none;
53
58
  }
54
59
 
60
+ /* flex-grow only matters in a height-bounded flex parent (the tab
61
+ pane): the textarea stretches to fill the note surface, pinning
62
+ the attribution toolbar to the bottom. In the card column the
63
+ parent is a plain block, so the notepad still hugs its text. */
64
+ :host([autogrow]) {
65
+ height: auto;
66
+ margin-top: 0;
67
+ flex-grow: 1;
68
+ /* in a height-bounded flex parent (tab pane) the autosize can
69
+ measure while flex-stretched and overshoot — allow flex to
70
+ shrink us back so the toolbar never overflows the pane */
71
+ min-height: 0;
72
+ }
73
+
74
+ /* hug the text — the JS autosize sets the exact height, so the only
75
+ floor is a single line for an empty note. border-box so setting
76
+ height = scrollHeight doesn't re-add the padding. Snug under the
77
+ card title, aligned to the 12px inset, with breathing room under
78
+ the last line of text. */
79
+ :host([autogrow]) .notepad {
80
+ box-sizing: border-box;
81
+ overflow-y: hidden;
82
+ min-height: 2em;
83
+ height: 2em;
84
+ padding: 0.25em 10px 1em 10px;
85
+ }
86
+
87
+ /* embedded in a card that supplies the chrome — drop our own so the
88
+ note surface bleeds to the card's edges */
89
+ :host([autogrow]) .wrapper {
90
+ border: none;
91
+ border-radius: 0;
92
+ box-shadow: none;
93
+ }
94
+
55
95
  .toolbar {
56
96
  background: rgba(0, 0, 0, 0.03);
57
97
  padding: 0.25em 0.5em;
@@ -73,8 +113,49 @@ export class ContactNotepad extends ContactStoreElement {
73
113
  `;
74
114
  }
75
115
 
116
+ private resizer: ResizeObserver;
117
+ private lastWidth = 0;
118
+
119
+ public connectedCallback(): void {
120
+ super.connectedCallback();
121
+ // text rewraps when our width changes (browser resize, layout mode
122
+ // switches), changing the height the note needs — only react to width
123
+ // so our own height writes don't loop the observer
124
+ this.resizer = new ResizeObserver(() => {
125
+ requestAnimationFrame(() => {
126
+ const width = this.offsetWidth;
127
+ if (width > 0 && width !== this.lastWidth) {
128
+ this.lastWidth = width;
129
+ this.autosize();
130
+ }
131
+ });
132
+ });
133
+ this.resizer.observe(this);
134
+ }
135
+
136
+ public disconnectedCallback(): void {
137
+ this.resizer?.disconnect();
138
+ super.disconnectedCallback();
139
+ }
140
+
76
141
  private handleChange() {
77
142
  this.markDirty();
143
+ // surface the unsaved state so a wrapping card can show its dirty mark
144
+ this.fireCustomEvent(CustomEventType.DetailsChanged, { dirty: true });
145
+ this.autosize();
146
+ }
147
+
148
+ private autosize() {
149
+ if (!this.autogrow) {
150
+ return;
151
+ }
152
+ const notepad = this.shadowRoot.querySelector(
153
+ '.notepad'
154
+ ) as HTMLTextAreaElement;
155
+ if (notepad) {
156
+ notepad.style.height = 'auto';
157
+ notepad.style.height = notepad.scrollHeight + 'px';
158
+ }
78
159
  }
79
160
 
80
161
  private submitChanges() {
@@ -94,14 +175,19 @@ export class ContactNotepad extends ContactStoreElement {
94
175
 
95
176
  if (changes.has('data')) {
96
177
  this.note =
97
- this.data?.notes.length > 0
178
+ this.data?.notes?.length > 0
98
179
  ? { ...this.data.notes[this.data.notes.length - 1] }
99
180
  : null;
100
181
  this.fireCustomEvent(CustomEventType.DetailsChanged, {
101
- count: this.note && this.note.text.length > 0 ? 1 : 0
182
+ count: this.note && this.note.text.length > 0 ? 1 : 0,
183
+ dirty: false
102
184
  });
103
185
  this.markClean();
104
186
  }
187
+
188
+ if (changes.has('note') || changes.has('data')) {
189
+ this.autosize();
190
+ }
105
191
  }
106
192
 
107
193
  public render(): TemplateResult {
@@ -1,8 +1,16 @@
1
1
  import { PropertyValues } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
- import { Contact, Group } from '../interfaces';
3
+ import { Contact, Group, URN } from '../interfaces';
4
4
  import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
5
5
 
6
+ /**
7
+ * Returns the URN that will be used to message the given contact — URNs are
8
+ * ordered by priority and only ones with a channel are sendable.
9
+ */
10
+ export const getDestinationURN = (contact: Contact | null): URN | null => {
11
+ return (contact?.urns || []).find((urn) => !!urn.channel) || null;
12
+ };
13
+
6
14
  export class ContactStoreElement extends EndpointMonitorElement {
7
15
  @property({ type: String })
8
16
  contact: string;
@@ -10,8 +18,10 @@ export class ContactStoreElement extends EndpointMonitorElement {
10
18
  @property({ type: Object, attribute: false })
11
19
  data: Contact;
12
20
 
21
+ // expand_urns resolves each URN against a channel so we know which one
22
+ // will be used when messaging the contact
13
23
  @property({ type: String })
14
- endpoint = '/api/v2/contacts.json?uuid=';
24
+ endpoint = '/api/v2/contacts.json?expand_urns=true&uuid=';
15
25
 
16
26
  prepareData(data: any) {
17
27
  if (data && data.length > 0) {
@@ -56,7 +66,8 @@ export class ContactStoreElement extends EndpointMonitorElement {
56
66
  }
57
67
 
58
68
  public willUpdate(changed: PropertyValues): void {
59
- 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)
60
71
  if (changed.has('contact') || changed.has('endpoint')) {
61
72
  if (this.contact) {
62
73
  this.url = `${this.endpoint}${this.contact}`;
@@ -64,5 +75,6 @@ export class ContactStoreElement extends EndpointMonitorElement {
64
75
  this.url = null;
65
76
  }
66
77
  }
78
+ super.willUpdate(changed);
67
79
  }
68
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`,
package/src/utils.ts CHANGED
@@ -17,6 +17,25 @@ export { isSystemCategory } from './flow/categoryUtils';
17
17
 
18
18
  export const DEFAULT_MEDIA_ENDPOINT = '/api/v2/media.json';
19
19
 
20
+ /**
21
+ * Normalize a message attachment into the `contentType:url` string that
22
+ * `temba-thumbnail` expects. The messages CRUDL endpoint serializes
23
+ * attachments as `{content_type, url}` objects, while other message
24
+ * surfaces (e.g. realtime contact events) carry them already as strings —
25
+ * accept either shape.
26
+ */
27
+ export const attachmentAsString = (attachment: string | Attachment): string => {
28
+ if (typeof attachment === 'string') {
29
+ return attachment;
30
+ }
31
+ // guard against malformed objects — an empty result is a no-op for
32
+ // temba-thumbnail, which beats rendering "undefined:…"
33
+ if (!attachment?.content_type || !attachment?.url) {
34
+ return '';
35
+ }
36
+ return `${attachment.content_type}:${attachment.url}`;
37
+ };
38
+
20
39
  export const colorHash = new ColorHash();
21
40
 
22
41
  export type Asset = KeyedAsset & Ticket & ContactField & Shortcut;