@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.
- package/CHANGELOG.md +14 -0
- package/dist/temba-components.js +1842 -678
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +3 -2
- package/src/display/Lightbox.ts +57 -109
- package/src/display/Thumbnail.ts +34 -7
- package/src/interfaces.ts +79 -2
- package/src/layout/Card.ts +311 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +33 -24
- package/src/layout/HeaderBar.ts +38 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +45 -6
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1094 -0
- package/src/live/ContactDetails.ts +3 -4
- package/src/live/ContactFields.ts +1 -1
- package/src/live/ContactNameFetch.ts +5 -2
- package/src/live/ContactNotepad.ts +88 -2
- package/src/live/ContactStoreElement.ts +12 -2
- package/src/utils.ts +19 -0
- package/temba-modules.ts +14 -0
- package/web-dev-server.config.mjs +54 -0
|
@@ -90,14 +90,13 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
90
90
|
</div>`
|
|
91
91
|
: null}
|
|
92
92
|
${this.data.urns.map((urn) => {
|
|
93
|
-
|
|
94
|
-
let scheme = SCHEMES[parts[0]];
|
|
93
|
+
let scheme = SCHEMES[urn.scheme];
|
|
95
94
|
if (!scheme) {
|
|
96
|
-
scheme = capitalize(
|
|
95
|
+
scheme = capitalize(urn.scheme as any);
|
|
97
96
|
}
|
|
98
97
|
return html`<temba-contact-field
|
|
99
98
|
name=${scheme}
|
|
100
|
-
value=${
|
|
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(
|
|
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=${
|
|
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
|
|
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-
|