@nyaruka/temba-components 0.168.1 → 0.170.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 +19 -0
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +1329 -1378
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +2 -2
- package/src/display/ContactName.ts +123 -1
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/form/Compose.ts +37 -28
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +3 -1
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +351 -8
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/ContactChat.ts +13 -1
- package/src/live/ContactDetails.ts +32 -1
- package/src/live/ContactFields.ts +34 -1
- package/src/live/ContactNotepad.ts +46 -11
- package/src/live/ContactStoreElement.ts +103 -1
- package/src/live/ContactWatch.ts +415 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/simulator/Simulator.ts +1 -0
- package/temba-modules.ts +0 -4
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
- package/src/live/ContactBadges.ts +0 -207
- package/src/live/ContactNameFetch.ts +0 -35
package/package.json
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { TemplateResult, html, nothing, PropertyValueMap, css } from 'lit';
|
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
3
|
import { repeat } from 'lit/directives/repeat.js';
|
|
4
4
|
import { RapidElement } from '../RapidElement';
|
|
5
|
-
import { CustomEventType } from '../interfaces';
|
|
5
|
+
import { CustomEventType, QuickReply } from '../interfaces';
|
|
6
6
|
import { TicketEvent } from '../events';
|
|
7
7
|
import { renderEventSummary } from '../events/eventRenderers';
|
|
8
8
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
@@ -81,7 +81,7 @@ interface User extends ObjectReference {
|
|
|
81
81
|
export interface Msg {
|
|
82
82
|
text: string;
|
|
83
83
|
channel: ObjectReference;
|
|
84
|
-
quick_replies: string[];
|
|
84
|
+
quick_replies: (string | QuickReply)[];
|
|
85
85
|
urn: string;
|
|
86
86
|
direction: string;
|
|
87
87
|
type: string;
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import { css, html, TemplateResult } from 'lit';
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
|
+
import { Contact, CustomEventType } from '../interfaces';
|
|
5
|
+
import { Events } from '../events/eventRenderers';
|
|
6
|
+
import { getDestinationURN } from '../live/ContactStoreElement';
|
|
7
|
+
import { watchContact } from '../live/ContactWatch';
|
|
8
|
+
import { RealtimeSubscription } from '../live/Realtime';
|
|
4
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A contact's name, optionally with the URN that will be used to message
|
|
12
|
+
* them. Given a contact uuid it manages itself: it registers interest in name
|
|
13
|
+
* events with the central contact watcher, which delivers the initial value
|
|
14
|
+
* and any live changes through the same handler - the component never fetches
|
|
15
|
+
* anything. Alternatively the name and urn can be set explicitly for static
|
|
16
|
+
* rendering (e.g. rows of a list that arrived in one fetch).
|
|
17
|
+
*/
|
|
5
18
|
export class ContactName extends RapidElement {
|
|
6
19
|
@property({ type: String })
|
|
7
20
|
name: string;
|
|
@@ -9,9 +22,25 @@ export class ContactName extends RapidElement {
|
|
|
9
22
|
@property({ type: String })
|
|
10
23
|
urn: string;
|
|
11
24
|
|
|
25
|
+
// when set, name and urn are kept live by the central contact watcher
|
|
26
|
+
@property({ type: String })
|
|
27
|
+
contact: string;
|
|
28
|
+
|
|
12
29
|
@property({ type: Number, attribute: 'icon-size' })
|
|
13
30
|
size = 20;
|
|
14
31
|
|
|
32
|
+
private watch: RealtimeSubscription = null;
|
|
33
|
+
private watchedContact: string = null;
|
|
34
|
+
|
|
35
|
+
// the contact the name and urn on screen came from - only what we put there
|
|
36
|
+
// is ours to clear, and only when we're pointed somewhere else
|
|
37
|
+
private displayedContact: string = null;
|
|
38
|
+
|
|
39
|
+
// the contact behind a refresh notification we've already queued - a name
|
|
40
|
+
// and a urn change arrive as separate deliveries, as does a burst of live
|
|
41
|
+
// events, so the notification is coalesced into a microtask
|
|
42
|
+
private pendingRefresh: Contact = null;
|
|
43
|
+
|
|
15
44
|
static get styles() {
|
|
16
45
|
return css`
|
|
17
46
|
:host {
|
|
@@ -34,6 +63,99 @@ export class ContactName extends RapidElement {
|
|
|
34
63
|
}
|
|
35
64
|
`;
|
|
36
65
|
}
|
|
66
|
+
|
|
67
|
+
public connectedCallback(): void {
|
|
68
|
+
super.connectedCallback();
|
|
69
|
+
this.syncWatch();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public disconnectedCallback(): void {
|
|
73
|
+
super.disconnectedCallback();
|
|
74
|
+
this.syncWatch();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public willUpdate(changed: PropertyValues): void {
|
|
78
|
+
super.willUpdate(changed);
|
|
79
|
+
if (changed.has('contact')) {
|
|
80
|
+
this.syncWatch();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keeps our watch in sync with the contact we're pointed at, clearing the
|
|
86
|
+
* previous contact's values when we switch contacts on the page. Explicitly
|
|
87
|
+
* set name/urn never pass through here since they involve no watch.
|
|
88
|
+
*/
|
|
89
|
+
private syncWatch() {
|
|
90
|
+
const target = (this.isConnected && this.contact) || null;
|
|
91
|
+
if (target === this.watchedContact) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.watch) {
|
|
96
|
+
this.watch.unsubscribe();
|
|
97
|
+
this.watch = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// only values we put there are ours to clear - a name and urn supplied
|
|
101
|
+
// alongside the contact render until the watcher has something better.
|
|
102
|
+
// Leaving the DOM isn't a switch, and blanking there would flash empty
|
|
103
|
+
// and refetch when we're only being moved - but a swap made while we were
|
|
104
|
+
// detached is, so compare against the contact on screen rather than the
|
|
105
|
+
// one we were last watching
|
|
106
|
+
if (
|
|
107
|
+
this.isConnected &&
|
|
108
|
+
this.displayedContact &&
|
|
109
|
+
this.displayedContact !== target
|
|
110
|
+
) {
|
|
111
|
+
this.name = null;
|
|
112
|
+
this.urn = null;
|
|
113
|
+
this.displayedContact = null;
|
|
114
|
+
}
|
|
115
|
+
this.watchedContact = target;
|
|
116
|
+
|
|
117
|
+
if (target) {
|
|
118
|
+
this.watch = watchContact(
|
|
119
|
+
target,
|
|
120
|
+
[Events.CONTACT_NAME_CHANGED, Events.CONTACT_URNS_CHANGED],
|
|
121
|
+
(event, contact) => this.handleContactEvent(event, contact)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private handleContactEvent(event: any, contact: Contact) {
|
|
127
|
+
if (contact) {
|
|
128
|
+
// the watcher keeps the contact current, so it's authoritative whether
|
|
129
|
+
// this delivery is an event or an initial value. Anon workspaces have
|
|
130
|
+
// no names, show the contact's ref instead
|
|
131
|
+
this.name = contact.name || contact.ref || null;
|
|
132
|
+
|
|
133
|
+
// show the URN that will be used when messaging the contact, falling
|
|
134
|
+
// back to their highest priority URN if none are sendable
|
|
135
|
+
const urn = getDestinationURN(contact) || (contact.urns || [])[0] || null;
|
|
136
|
+
this.urn = urn ? `${urn.scheme}:${urn.display || urn.path}` : null;
|
|
137
|
+
this.displayedContact = this.watchedContact;
|
|
138
|
+
this.fireRefreshed(contact);
|
|
139
|
+
} else if (event && event.type === Events.CONTACT_NAME_CHANGED) {
|
|
140
|
+
// a live event can outrun the initial fetch - show the name we have
|
|
141
|
+
this.name = event.name || null;
|
|
142
|
+
this.displayedContact = this.watchedContact;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fireRefreshed(contact: Contact) {
|
|
147
|
+
if (!this.pendingRefresh) {
|
|
148
|
+
Promise.resolve().then(() => {
|
|
149
|
+
const refreshed = this.pendingRefresh;
|
|
150
|
+
this.pendingRefresh = null;
|
|
151
|
+
if (refreshed) {
|
|
152
|
+
this.fireCustomEvent(CustomEventType.Refreshed, { data: refreshed });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
this.pendingRefresh = contact;
|
|
157
|
+
}
|
|
158
|
+
|
|
37
159
|
public render(): TemplateResult {
|
|
38
160
|
const urn = this.urn
|
|
39
161
|
? html`<temba-urn size=${this.size} urn=${this.urn}></temba-urn>`
|
package/src/display/Dropdown.ts
CHANGED
|
@@ -9,9 +9,6 @@ import { styleMap } from 'lit-html/directives/style-map.js';
|
|
|
9
9
|
export class Dropdown extends RapidElement {
|
|
10
10
|
static get styles() {
|
|
11
11
|
return css`
|
|
12
|
-
:host {
|
|
13
|
-
}
|
|
14
|
-
|
|
15
12
|
.wrapper {
|
|
16
13
|
position: relative;
|
|
17
14
|
}
|
|
@@ -25,9 +22,6 @@ export class Dropdown extends RapidElement {
|
|
|
25
22
|
overflow: auto;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
.dropdown:focus {
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
.dropdown.dormant {
|
|
32
26
|
height: 0;
|
|
33
27
|
overflow: hidden;
|
|
@@ -35,7 +29,16 @@ export class Dropdown extends RapidElement {
|
|
|
35
29
|
|
|
36
30
|
.dropdown {
|
|
37
31
|
position: fixed;
|
|
38
|
-
|
|
32
|
+
/* open popups sit above floating windows (simulator, 5000) and
|
|
33
|
+
other page chrome, but below dialogs and toasts (10000);
|
|
34
|
+
temba-content-menu mirrors this value on its temba-dropdown
|
|
35
|
+
flex item (see ContentMenu.ts) */
|
|
36
|
+
z-index: 9000;
|
|
37
|
+
/* the dormant (height: 0) rule is applied 250ms after close, so
|
|
38
|
+
until then this stays a laid-out, invisible fixed box at
|
|
39
|
+
z-index 9000 over whatever it was covering — ignore pointer
|
|
40
|
+
events unless we're actually open */
|
|
41
|
+
pointer-events: none;
|
|
39
42
|
padding: 0;
|
|
40
43
|
opacity: 0;
|
|
41
44
|
border-radius: calc(var(--curvature) * 1.5);
|
|
@@ -67,6 +70,7 @@ export class Dropdown extends RapidElement {
|
|
|
67
70
|
|
|
68
71
|
.open .dropdown {
|
|
69
72
|
opacity: 1;
|
|
73
|
+
pointer-events: auto;
|
|
70
74
|
transform: translateY(0.5em) scale(1);
|
|
71
75
|
}
|
|
72
76
|
|
package/src/display/Options.ts
CHANGED
|
@@ -338,6 +338,13 @@ export class Options extends RapidElement {
|
|
|
338
338
|
@property({ attribute: false })
|
|
339
339
|
renderOption: { (option: any, selected: boolean): TemplateResult };
|
|
340
340
|
|
|
341
|
+
// renders between rows (and above the first) when the data crosses a
|
|
342
|
+
// grouping boundary - the result sits outside the option rows so it never
|
|
343
|
+
// picks up their hover or selection treatment. Returning null means the
|
|
344
|
+
// rows aren't a boundary and nothing is rendered between them.
|
|
345
|
+
@property({ attribute: false })
|
|
346
|
+
renderDivider: { (prev: any, option: any): TemplateResult | null };
|
|
347
|
+
|
|
341
348
|
@property({ attribute: false })
|
|
342
349
|
renderOptionName: { (option: any, selected: boolean): TemplateResult };
|
|
343
350
|
|
|
@@ -816,20 +823,26 @@ export class Options extends RapidElement {
|
|
|
816
823
|
<div class="${classesInner}" style=${styleMap(optionsStyle)}>
|
|
817
824
|
${options.length > 0
|
|
818
825
|
? options.map((option, index) => {
|
|
819
|
-
return html
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
option
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
826
|
+
return html`${this.renderDivider
|
|
827
|
+
? this.renderDivider(
|
|
828
|
+
index > 0 ? options[index - 1] : null,
|
|
829
|
+
option
|
|
830
|
+
)
|
|
831
|
+
: null}
|
|
832
|
+
<div
|
|
833
|
+
data-option-index="${index}"
|
|
834
|
+
@mousemove=${this.handleMouseMove}
|
|
835
|
+
@mousedown=${this.handleOptionClick}
|
|
836
|
+
class="option ${index === this.cursorIndex &&
|
|
837
|
+
!this.internalFocusDisabled
|
|
838
|
+
? 'focused'
|
|
839
|
+
: ''}"
|
|
840
|
+
>
|
|
841
|
+
${this.resolvedRenderOption(
|
|
842
|
+
option,
|
|
843
|
+
index === this.cursorIndex
|
|
844
|
+
)}
|
|
845
|
+
</div>`;
|
|
833
846
|
})
|
|
834
847
|
: this.visible && this.showEmptyMessage
|
|
835
848
|
? html`<div
|
package/src/display/TembaDate.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
1
|
+
import { css, html, nothing, PropertyValues, TemplateResult } from 'lit';
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
4
|
import { Store } from '../store/Store';
|
|
@@ -47,7 +47,14 @@ export class TembaDate extends RapidElement {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
public render(): TemplateResult {
|
|
50
|
+
public render(): TemplateResult | typeof nothing {
|
|
51
|
+
// unparseable values and zero-value times (e.g. go's zero time,
|
|
52
|
+
// 0001-01-01T00:00:00Z, for a contact that has never been seen) aren't
|
|
53
|
+
// real dates - render nothing rather than something like "2025 years ago"
|
|
54
|
+
if (this.datetime && !(this.datetime.isValid && this.datetime.year > 1)) {
|
|
55
|
+
return nothing;
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
if (this.datetime && this.store) {
|
|
52
59
|
this.datetime.setLocale(this.store.getLocale());
|
|
53
60
|
|
package/src/form/Compose.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { TemplateResult, html, css, PropertyValues, nothing } from 'lit';
|
|
2
2
|
import { FieldElement } from './FieldElement';
|
|
3
3
|
import { property } from 'lit/decorators.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Attachment,
|
|
6
|
+
CustomEventType,
|
|
7
|
+
Language,
|
|
8
|
+
QuickReply,
|
|
9
|
+
Shortcut
|
|
10
|
+
} from '../interfaces';
|
|
5
11
|
import { Icon } from '../Icons';
|
|
6
12
|
import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
|
|
7
13
|
import { Select } from './select/Select';
|
|
@@ -12,7 +18,7 @@ import { setCaretOffset } from '../excellent/caret-utils';
|
|
|
12
18
|
export interface ComposeValue {
|
|
13
19
|
text: string;
|
|
14
20
|
attachments: { uuid: string }[];
|
|
15
|
-
quick_replies: string[];
|
|
21
|
+
quick_replies: (string | QuickReply)[];
|
|
16
22
|
optin: string;
|
|
17
23
|
template: string;
|
|
18
24
|
variables: string[];
|
|
@@ -236,7 +242,7 @@ export class Compose extends FieldElement {
|
|
|
236
242
|
[lang: string]: {
|
|
237
243
|
text: string;
|
|
238
244
|
attachments: Attachment[];
|
|
239
|
-
quick_replies: string[];
|
|
245
|
+
quick_replies: (string | QuickReply)[];
|
|
240
246
|
optin?: { name: string; uuid: string };
|
|
241
247
|
template?: string;
|
|
242
248
|
variables?: string[];
|
|
@@ -357,11 +363,12 @@ export class Compose extends FieldElement {
|
|
|
357
363
|
this.currentText = langValue.text || '';
|
|
358
364
|
this.initialText = langValue.text || '';
|
|
359
365
|
this.currentAttachments = langValue.attachments || [];
|
|
360
|
-
this.currentQuickReplies = (langValue.quick_replies || [])
|
|
361
|
-
(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
366
|
+
this.currentQuickReplies = (langValue.quick_replies || [])
|
|
367
|
+
.map((reply) =>
|
|
368
|
+
typeof reply === 'string' ? reply : (reply.text ?? null)
|
|
369
|
+
)
|
|
370
|
+
.filter((reply): reply is string => reply !== null)
|
|
371
|
+
.map((value) => ({ name: value, value }));
|
|
365
372
|
this.currentOptin = langValue['optin'] ? [langValue['optin']] : [];
|
|
366
373
|
}
|
|
367
374
|
|
|
@@ -410,25 +417,14 @@ export class Compose extends FieldElement {
|
|
|
410
417
|
// own keydown inserts a newline (which would flash before the send
|
|
411
418
|
// clears it); bubble keeps Enter-to-send working from anywhere else
|
|
412
419
|
// in the compose after inner widgets have had their shot at it
|
|
413
|
-
this.addEventListener(
|
|
414
|
-
|
|
415
|
-
this.handleHostKeyDown as EventListener,
|
|
416
|
-
true
|
|
417
|
-
);
|
|
418
|
-
this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
|
|
420
|
+
this.addEventListener('keydown', this.handleHostKeyDownCapture, true);
|
|
421
|
+
this.addEventListener('keydown', this.handleHostKeyDownBubble);
|
|
419
422
|
}
|
|
420
423
|
|
|
421
424
|
disconnectedCallback() {
|
|
422
425
|
super.disconnectedCallback();
|
|
423
|
-
this.removeEventListener(
|
|
424
|
-
|
|
425
|
-
this.handleHostKeyDown as EventListener,
|
|
426
|
-
true
|
|
427
|
-
);
|
|
428
|
-
this.removeEventListener(
|
|
429
|
-
'keydown',
|
|
430
|
-
this.handleHostKeyDown as EventListener
|
|
431
|
-
);
|
|
426
|
+
this.removeEventListener('keydown', this.handleHostKeyDownCapture, true);
|
|
427
|
+
this.removeEventListener('keydown', this.handleHostKeyDownBubble);
|
|
432
428
|
}
|
|
433
429
|
|
|
434
430
|
private handleShortcutIconClick() {
|
|
@@ -454,7 +450,18 @@ export class Compose extends FieldElement {
|
|
|
454
450
|
});
|
|
455
451
|
}
|
|
456
452
|
|
|
457
|
-
|
|
453
|
+
// events from inside our shadow tree are retargeted to the host, so
|
|
454
|
+
// both registrations see eventPhase as AT_TARGET — the phase can't
|
|
455
|
+
// tell us which registration is firing, so each binds it explicitly
|
|
456
|
+
private handleHostKeyDownCapture = (evt: KeyboardEvent) => {
|
|
457
|
+
this.handleHostKeyDown(evt, true);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
private handleHostKeyDownBubble = (evt: KeyboardEvent) => {
|
|
461
|
+
this.handleHostKeyDown(evt, false);
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
private handleHostKeyDown = (evt: KeyboardEvent, capture: boolean) => {
|
|
458
465
|
if (evt.key === 'Escape' && this.showShortcuts) {
|
|
459
466
|
evt.preventDefault();
|
|
460
467
|
evt.stopPropagation();
|
|
@@ -474,10 +481,12 @@ export class Compose extends FieldElement {
|
|
|
474
481
|
// during capture only editor presses are claimed — anything else
|
|
475
482
|
// (quick replies, attachments) waits for the bubble so the widget
|
|
476
483
|
// it targets keeps first claim on the event
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
484
|
+
if (capture && !(editor && evt.composedPath().includes(editor))) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// by the bubble an inner widget may have claimed the Enter for
|
|
488
|
+
// itself (e.g. the quick reply select adding a tag) — not a send
|
|
489
|
+
if (!capture && evt.defaultPrevented) {
|
|
481
490
|
return;
|
|
482
491
|
}
|
|
483
492
|
if (editor) {
|
package/src/interfaces.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface Attachment {
|
|
|
47
47
|
error: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A quick reply as stored/served by the server (engine shape) —
|
|
51
|
+
* `text` buttons carry text, `location` requests may omit it. */
|
|
52
|
+
export interface QuickReply {
|
|
53
|
+
type?: string;
|
|
54
|
+
text?: string;
|
|
55
|
+
extra?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export enum DateStyle {
|
|
51
59
|
DayFirst = 'day_first',
|
|
52
60
|
MonthFirst = 'month_first',
|
|
@@ -231,8 +239,9 @@ export interface Broadcast {
|
|
|
231
239
|
/** Base-language attachments (`{content_type, url}` objects or
|
|
232
240
|
* `contentType:url` strings). */
|
|
233
241
|
attachments?: (string | Attachment)[];
|
|
234
|
-
/** Base-language quick replies
|
|
235
|
-
|
|
242
|
+
/** Base-language quick replies — engine `{text}` objects, with
|
|
243
|
+
* plain strings tolerated for older data. */
|
|
244
|
+
quick_replies?: (string | QuickReply)[];
|
|
236
245
|
/** The opt-in the broadcast requests, when it is one. */
|
|
237
246
|
optin?: ObjectReference | null;
|
|
238
247
|
/** The WhatsApp template the broadcast sends, when it uses one. */
|
|
@@ -271,7 +280,7 @@ export interface Msg {
|
|
|
271
280
|
text: string;
|
|
272
281
|
status: string;
|
|
273
282
|
channel: ObjectReference;
|
|
274
|
-
quick_replies: string[];
|
|
283
|
+
quick_replies: (string | QuickReply)[];
|
|
275
284
|
urn: string;
|
|
276
285
|
/** The message's contact — populated by the messages CRUDL
|
|
277
286
|
* endpoint. Carries whichever of name/urn the endpoint exposes. */
|
|
@@ -479,6 +488,7 @@ export enum CustomEventType {
|
|
|
479
488
|
StoreUpdated = 'temba-store-updated',
|
|
480
489
|
Ready = 'temba-ready',
|
|
481
490
|
OrderChanged = 'temba-order-changed',
|
|
491
|
+
ColumnOrderChanged = 'temba-column-order-changed',
|
|
482
492
|
DragStart = 'temba-drag-start',
|
|
483
493
|
DragStop = 'temba-drag-stop',
|
|
484
494
|
DragExternal = 'temba-drag-external',
|
|
@@ -872,7 +872,9 @@ export class BroadcastList extends ContentList<Broadcast> {
|
|
|
872
872
|
${broadcast.quick_replies.map(
|
|
873
873
|
(reply) =>
|
|
874
874
|
html`<temba-label type="neutral"
|
|
875
|
-
>${reply
|
|
875
|
+
>${typeof reply === 'string'
|
|
876
|
+
? reply
|
|
877
|
+
: (reply.text ?? reply.type)}</temba-label
|
|
876
878
|
>`
|
|
877
879
|
)}
|
|
878
880
|
</div>`
|
package/src/list/ContactList.ts
CHANGED
|
@@ -3,13 +3,19 @@ import { property, state } from 'lit/decorators.js';
|
|
|
3
3
|
import { ContentList, ContentListColumn } from './ContentList';
|
|
4
4
|
import { Icon } from '../Icons';
|
|
5
5
|
import { Contact } from '../interfaces';
|
|
6
|
-
import { getUrl } from '../utils';
|
|
6
|
+
import { getUrl, postJSON } from '../utils';
|
|
7
|
+
import { getStore } from '../store/Store';
|
|
7
8
|
|
|
8
9
|
const FIELD_PREFIX = 'field:';
|
|
9
10
|
|
|
10
11
|
/** Placeholder shown in any cell whose value is empty. */
|
|
11
12
|
const EMPTY = '--';
|
|
12
13
|
|
|
14
|
+
/** Ceiling on how many pages of fields we'll walk. The API serves 250
|
|
15
|
+
* per page, so this is far past any real workspace — it exists only so
|
|
16
|
+
* an endpoint that keeps handing back a `next` can't spin forever. */
|
|
17
|
+
const MAX_FIELD_PAGES = 20;
|
|
18
|
+
|
|
13
19
|
/**
|
|
14
20
|
* Contact CRUDL list — drop-in replacement for the rapidpro
|
|
15
21
|
* `contacts/contact_list.html` table. Each row carries a contact
|
|
@@ -22,6 +28,11 @@ const EMPTY = '--';
|
|
|
22
28
|
* {@link ContactList.fieldsEndpoint} on connect; cells read each
|
|
23
29
|
* contact's value out of `item.fields[<key>]`. Date/time fields
|
|
24
30
|
* render as a relative duration, matching the Last-seen column.
|
|
31
|
+
*
|
|
32
|
+
* When {@link ContactList.priorityEndpoint} is set the field columns
|
|
33
|
+
* can be dragged into a new order, which is saved as the workspace's
|
|
34
|
+
* featured-field order (the list renders featured fields by priority,
|
|
35
|
+
* so column order and field order are the same thing).
|
|
25
36
|
*/
|
|
26
37
|
export class ContactList extends ContentList<Contact> {
|
|
27
38
|
static get styles() {
|
|
@@ -45,10 +56,25 @@ export class ContactList extends ContentList<Contact> {
|
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/** Endpoint returning `{ results: ContactField[] }`. Fields where
|
|
48
|
-
* `featured: true` become extra columns.
|
|
59
|
+
* `featured: true` become extra columns.
|
|
60
|
+
*
|
|
61
|
+
* The list deliberately fetches fields itself instead of reading the
|
|
62
|
+
* global store: it has to work standalone (and against a custom
|
|
63
|
+
* endpoint), and it may mount before any store exists on the page.
|
|
64
|
+
* The store, when present, is only *told* about changes — see the
|
|
65
|
+
* refresh after a priority save. The endpoint has no featured filter,
|
|
66
|
+
* so the fetch pages through all fields and filters client-side. */
|
|
49
67
|
@property({ type: String, attribute: 'fields-endpoint' })
|
|
50
68
|
fieldsEndpoint = '/api/v2/fields.json';
|
|
51
69
|
|
|
70
|
+
/** Endpoint accepting `{ featured: [keys...] }` to save featured-field
|
|
71
|
+
* order (the same one the fields management page posts to). When set,
|
|
72
|
+
* the field columns become drag-reorderable and a drop saves the new
|
|
73
|
+
* order org-wide; the host only sets it for users holding the
|
|
74
|
+
* update-priority permission. */
|
|
75
|
+
@property({ type: String, attribute: 'priority-endpoint' })
|
|
76
|
+
priorityEndpoint = '';
|
|
77
|
+
|
|
52
78
|
/** Anonymous workspaces mask URN values, so instead of the URN
|
|
53
79
|
* column the list shows each contact's ref (served as its own
|
|
54
80
|
* anon-only key by the endpoint). */
|
|
@@ -104,7 +130,7 @@ export class ContactList extends ContentList<Contact> {
|
|
|
104
130
|
// Rebuilding here (rather than in updated()) means a mounted
|
|
105
131
|
// anon attribute is reflected in the first paint instead of
|
|
106
132
|
// flashing the URN header for a frame.
|
|
107
|
-
if (changes.has('anon')) {
|
|
133
|
+
if (changes.has('anon') || changes.has('priorityEndpoint')) {
|
|
108
134
|
this.columns = this.buildColumns();
|
|
109
135
|
}
|
|
110
136
|
}
|
|
@@ -127,11 +153,27 @@ export class ContactList extends ContentList<Contact> {
|
|
|
127
153
|
const controller = new AbortController();
|
|
128
154
|
this.pendingFieldsController = controller;
|
|
129
155
|
try {
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
// The fields API is cursor paginated (250 per page), and the
|
|
157
|
+
// featured list a drop posts back is authoritative — any featured
|
|
158
|
+
// field we never read would be silently un-featured org-wide. So
|
|
159
|
+
// walk every page before deciding what's featured. Nothing is
|
|
160
|
+
// assigned until the whole walk lands, so a page that fails leaves
|
|
161
|
+
// the previous (complete) list in place rather than a truncated one.
|
|
162
|
+
const all: any[] = [];
|
|
163
|
+
const fetched = new Set<string>();
|
|
164
|
+
let url: string = this.fieldsEndpoint;
|
|
165
|
+
for (let page = 0; url && page < MAX_FIELD_PAGES; page++) {
|
|
166
|
+
// an endpoint whose `next` points back at a page we've already
|
|
167
|
+
// read would otherwise loop until the page cap
|
|
168
|
+
if (fetched.has(url)) break;
|
|
169
|
+
fetched.add(url);
|
|
170
|
+
const response = await getUrl(url, controller);
|
|
171
|
+
// If the controller has been swapped or cleared, the response
|
|
172
|
+
// is from a stale request — drop it on the floor.
|
|
173
|
+
if (this.pendingFieldsController !== controller) return;
|
|
174
|
+
all.push(...(response.json?.results || []));
|
|
175
|
+
url = response.json?.next || '';
|
|
176
|
+
}
|
|
135
177
|
this.featuredFields = all
|
|
136
178
|
.filter((f: any) => f.featured)
|
|
137
179
|
.sort((a: any, b: any) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
@@ -173,7 +215,11 @@ export class ContactList extends ContentList<Contact> {
|
|
|
173
215
|
sortable: true,
|
|
174
216
|
resizeMinWidth: '40px',
|
|
175
217
|
maxWidth: '200px',
|
|
176
|
-
resizable: true
|
|
218
|
+
resizable: true,
|
|
219
|
+
// Field columns can be dragged into a new order, but only when
|
|
220
|
+
// the host wired up somewhere to save it — reordering that
|
|
221
|
+
// silently reverts on reload would read as broken.
|
|
222
|
+
reorderable: !!this.priorityEndpoint
|
|
177
223
|
})
|
|
178
224
|
);
|
|
179
225
|
// Name + URN are the pinned identity columns and are not
|
|
@@ -224,6 +270,64 @@ export class ContactList extends ContentList<Contact> {
|
|
|
224
270
|
];
|
|
225
271
|
}
|
|
226
272
|
|
|
273
|
+
/** A committed header drag reordered the field columns — keep
|
|
274
|
+
* featuredFields aligned with the new column order (so any rebuild
|
|
275
|
+
* preserves it) and save it as the workspace's featured-field order.
|
|
276
|
+
* Column order maps directly onto priority: leftmost = highest, the
|
|
277
|
+
* same contract as the fields management page, whose endpoint this
|
|
278
|
+
* posts to with the full featured list. */
|
|
279
|
+
protected onColumnOrderChanged(columns: ContentListColumn[]): void {
|
|
280
|
+
// A fields fetch still in flight was started against the old order
|
|
281
|
+
// and would land on top of the drop, snapping the columns back with
|
|
282
|
+
// the new order already on its way to the server.
|
|
283
|
+
if (this.pendingFieldsController) {
|
|
284
|
+
this.pendingFieldsController.abort();
|
|
285
|
+
this.pendingFieldsController = undefined;
|
|
286
|
+
}
|
|
287
|
+
const keys = columns
|
|
288
|
+
.filter((c) => c.key.startsWith(FIELD_PREFIX))
|
|
289
|
+
.map((c) => c.key.substring(FIELD_PREFIX.length));
|
|
290
|
+
this.featuredFields = keys
|
|
291
|
+
.map((key) => (this.featuredFields || []).find((f: any) => f.key === key))
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
if (!this.priorityEndpoint) return;
|
|
294
|
+
postJSON(this.priorityEndpoint, { featured: keys })
|
|
295
|
+
.then((response) => {
|
|
296
|
+
// postUrl only rejects on 5xx, so a 400/403/404 arrives here as a
|
|
297
|
+
// perfectly resolved promise — the order was never stored and we
|
|
298
|
+
// have to treat it as the failure it is.
|
|
299
|
+
if (response.status < 200 || response.status >= 300) {
|
|
300
|
+
throw response;
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
.catch((error) => {
|
|
304
|
+
console.warn('failed to save featured field order', error);
|
|
305
|
+
// The list went away while the save was in flight — there are no
|
|
306
|
+
// columns left to put back, and refetching would only leave a
|
|
307
|
+
// request outstanding against a detached element.
|
|
308
|
+
if (!this.isConnected) return;
|
|
309
|
+
// Nothing made the dragged order durable, so stop showing it —
|
|
310
|
+
// refetch and fall back on whatever the server still holds. The
|
|
311
|
+
// success path deliberately doesn't refetch: we already display
|
|
312
|
+
// exactly what we just saved, and a read-replica answering with
|
|
313
|
+
// pre-write data would yank the columns back for no reason.
|
|
314
|
+
this.loadFields();
|
|
315
|
+
})
|
|
316
|
+
.finally(() => {
|
|
317
|
+
// Featured fields are workspace state other components on the
|
|
318
|
+
// page read from the store (contact details, the fields page),
|
|
319
|
+
// so its cached copy has to hear about the new order too. Looked
|
|
320
|
+
// up here rather than cached on connect so a list that mounted
|
|
321
|
+
// ahead of the store still finds it.
|
|
322
|
+
getStore()
|
|
323
|
+
?.refreshFields()
|
|
324
|
+
.catch(() => {
|
|
325
|
+
// a stale store cache is cosmetic and elsewhere; the list
|
|
326
|
+
// itself is already showing the saved order
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
227
331
|
protected getRowIcon(_item: Contact): string | null {
|
|
228
332
|
return Icon.contact;
|
|
229
333
|
}
|