@nyaruka/temba-components 0.163.0 → 0.164.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.
@@ -90,14 +90,13 @@ export class ContactDetails extends ContactStoreElement {
90
90
  </div>`
91
91
  : null}
92
92
  ${this.data.urns.map((urn) => {
93
- const parts = urn.split(':');
94
- let scheme = SCHEMES[parts[0]];
93
+ let scheme = SCHEMES[urn.scheme];
95
94
  if (!scheme) {
96
- scheme = capitalize(parts[0] as any);
95
+ scheme = capitalize(urn.scheme as any);
97
96
  }
98
97
  return html`<temba-contact-field
99
98
  name=${scheme}
100
- value=${parts[1]}
99
+ value=${urn.display || urn.path}
101
100
  disabled
102
101
  ></temba-contact-field>`;
103
102
  })}
@@ -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) {
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;
package/temba-modules.ts CHANGED
@@ -32,6 +32,7 @@ import { ContactFieldEditor } from './src/live/ContactFieldEditor';
32
32
 
33
33
  import { ContactBadges } from './src/live/ContactBadges';
34
34
  import { ContactTimeline } from './src/live/ContactTimeline';
35
+ import { CampaignEvents } from './src/live/CampaignEvents';
35
36
  import { TembaSlider } from './src/form/TembaSlider';
36
37
  import { RunList } from './src/list/RunList';
37
38
  import { FlowStoreElement } from './src/store/FlowStoreElement';
@@ -49,9 +50,12 @@ import { Thumbnail } from './src/display/Thumbnail';
49
50
  import { NotificationList } from './src/list/NotificationList';
50
51
  import { ContentList } from './src/list/ContentList';
51
52
  import { PageHeader } from './src/layout/PageHeader';
53
+ import { HeaderBar } from './src/layout/HeaderBar';
52
54
  import { MsgList } from './src/list/MsgList';
53
55
  import { ContactList } from './src/list/ContactList';
54
56
  import { FlowList } from './src/list/FlowList';
57
+ import { CampaignList } from './src/list/CampaignList';
58
+ import { TriggerList } from './src/list/TriggerList';
55
59
  import { WebChat } from './src/webchat/WebChat';
56
60
  import { ImagePicker } from './src/form/ImagePicker';
57
61
  import { Mask } from './src/layout/Mask';
@@ -86,6 +90,9 @@ import { FloatingTab } from './src/display/FloatingTab';
86
90
  import { FloatingWindow } from './src/layout/FloatingWindow';
87
91
  import { Accordion } from './src/layout/Accordion';
88
92
  import { AccordionSection } from './src/layout/AccordionSection';
93
+ import { Card } from './src/layout/Card';
94
+ import { CardStack } from './src/layout/CardStack';
95
+ import { CardLayout } from './src/layout/CardLayout';
89
96
  import { Simulator } from './src/simulator/Simulator';
90
97
  import { FlowSearch } from './src/flow/FlowSearch';
91
98
  import { IssuesWindow } from './src/flow/IssuesWindow';
@@ -134,9 +141,12 @@ addCustomElement('temba-ticket-list', TicketList);
134
141
  addCustomElement('temba-notification-list', NotificationList);
135
142
  addCustomElement('temba-content-list', ContentList);
136
143
  addCustomElement('temba-page-header', PageHeader);
144
+ addCustomElement('temba-header-bar', HeaderBar);
137
145
  addCustomElement('temba-msg-list', MsgList);
138
146
  addCustomElement('temba-contact-list', ContactList);
139
147
  addCustomElement('temba-flow-list', FlowList);
148
+ addCustomElement('temba-campaign-list', CampaignList);
149
+ addCustomElement('temba-trigger-list', TriggerList);
140
150
  addCustomElement('temba-list', TembaList);
141
151
  addCustomElement('temba-sortable-list', SortableList);
142
152
  addCustomElement('temba-run-list', RunList);
@@ -150,6 +160,7 @@ addCustomElement('temba-tabs', TabPane);
150
160
  addCustomElement('temba-tab', Tab);
151
161
  addCustomElement('temba-contact-badges', ContactBadges);
152
162
  addCustomElement('temba-contact-timeline', ContactTimeline);
163
+ addCustomElement('temba-campaign-events', CampaignEvents);
153
164
  addCustomElement('temba-slider', TembaSlider);
154
165
  addCustomElement('temba-content-menu', ContentMenu);
155
166
  addCustomElement('temba-compose', Compose);
@@ -186,6 +197,9 @@ addCustomElement('temba-message-editor', MessageEditor);
186
197
  addCustomElement('temba-rich-edit', RichEditor);
187
198
  addCustomElement('temba-accordion', Accordion);
188
199
  addCustomElement('temba-accordion-section', AccordionSection);
200
+ addCustomElement('temba-card', Card);
201
+ addCustomElement('temba-card-stack', CardStack);
202
+ addCustomElement('temba-card-layout', CardLayout);
189
203
  addCustomElement('temba-expression-highlight', ExpressionHighlight);
190
204
  addCustomElement('temba-floating-tab', FloatingTab);
191
205
  addCustomElement('temba-floating-window', FloatingWindow);
@@ -450,6 +450,60 @@ export default {
450
450
  return;
451
451
  }
452
452
 
453
+ // Handle contact timeline (upcoming / past scheduled events).
454
+ // Dates are generated relative to now so the demo never goes stale.
455
+ if (context.request.method === 'GET' && context.path.match(/^\/contact\/timeline\/[^/]+\/$/)) {
456
+ const now = new Date();
457
+ const days = n => new Date(now.getTime() + n * 86400000).toISOString();
458
+ const campaign = { uuid: 'campaign-1', name: 'Customer Onboarding' };
459
+ context.contentType = 'application/json';
460
+ context.body = JSON.stringify({
461
+ now: now.toISOString(),
462
+ campaigns: [campaign],
463
+ future_count: 3,
464
+ future: [
465
+ {
466
+ type: 'campaign_event',
467
+ scheduled: days(2),
468
+ repeat_period: 'O',
469
+ campaign,
470
+ flow: { uuid: 'flow-1', name: 'Welcome Check-in' }
471
+ },
472
+ {
473
+ type: 'scheduled_broadcast',
474
+ scheduled: days(5),
475
+ repeat_period: 'W',
476
+ message: 'Weekly tips: how to get the most out of your account'
477
+ },
478
+ {
479
+ type: 'campaign_event',
480
+ scheduled: days(9),
481
+ repeat_period: 'O',
482
+ campaign,
483
+ flow: { uuid: 'flow-2', name: 'Satisfaction Survey' }
484
+ }
485
+ ],
486
+ past: [
487
+ {
488
+ type: 'campaign_event',
489
+ scheduled: days(-3),
490
+ repeat_period: 'O',
491
+ campaign,
492
+ flow: { uuid: 'flow-3', name: 'Getting Started' }
493
+ },
494
+ {
495
+ type: 'sent_broadcast',
496
+ scheduled: days(-7),
497
+ repeat_period: 'O',
498
+ message: 'Welcome aboard!'
499
+ }
500
+ ],
501
+ next_before: null,
502
+ next_after: null
503
+ });
504
+ return;
505
+ }
506
+
453
507
  // Serve the content-list demo messages from in-memory state
454
508
  // so a labeling POST is reflected on the next refresh. Honors
455
509
  // an optional `?label=<uuid>` filter for testing the filtered-