@nyaruka/temba-components 0.167.0 → 0.168.1
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 +27 -0
- package/dist/locales/es.js +3 -1
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +3 -1
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +3 -1
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +2842 -1715
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/Icons.ts +2 -0
- package/src/display/Button.ts +8 -6
- package/src/display/Chat.ts +541 -191
- package/src/display/Dropdown.ts +40 -17
- package/src/display/Label.ts +25 -4
- package/src/display/TembaDate.ts +11 -2
- package/src/display/Tip.ts +157 -18
- package/src/events/eventRenderers.ts +678 -268
- package/src/events.ts +16 -1
- package/src/flow/NodeEditor.ts +82 -9
- package/src/flow/actions/add_contact_urn.ts +1 -0
- package/src/flow/actions/play_audio.ts +1 -0
- package/src/flow/actions/send_broadcast.ts +17 -1
- package/src/flow/actions/send_msg.ts +12 -1
- package/src/flow/actions/set_run_result.ts +1 -0
- package/src/flow/actions/start_session.ts +10 -1
- package/src/flow/nodes/shared-rules.ts +2 -0
- package/src/flow/nodes/split_by_airtime.ts +6 -0
- package/src/flow/nodes/split_by_expression.ts +1 -0
- package/src/flow/nodes/split_by_llm.ts +2 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
- package/src/flow/nodes/split_by_random.ts +2 -1
- package/src/flow/nodes/split_by_resthook.ts +4 -1
- package/src/flow/nodes/split_by_webhook.ts +9 -1
- package/src/flow/types.ts +3 -0
- package/src/flow/utils.ts +49 -0
- package/src/form/DatePicker.ts +2 -1
- package/src/layout/Card.ts +9 -0
- package/src/layout/CardLayout.ts +426 -21
- package/src/layout/CardStack.ts +4 -1
- package/src/list/ContactList.ts +24 -17
- package/src/list/ContentList.ts +640 -33
- package/src/list/MsgList.ts +7 -1
- package/src/list/SortableList.ts +5 -1
- package/src/list/TembaMenu.ts +67 -6
- package/src/live/ContactChat.ts +655 -336
- package/src/live/ContactDetails.ts +961 -88
- package/src/live/ContactFieldEditor.ts +46 -11
- package/src/live/ContactFields.ts +57 -16
- package/src/live/ContactStoreElement.ts +15 -8
- package/src/locales/es.ts +3 -2
- package/src/locales/fr.ts +3 -2
- package/src/locales/pt.ts +3 -2
- package/src/simulator/Simulator.ts +28 -0
- package/src/styles/pillVariants.ts +13 -1
- package/web-test-runner.config.mjs +2 -0
- package/xliff/es.xlf +6 -3
- package/xliff/fr.xlf +6 -3
- package/xliff/pt.xlf +6 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html, TemplateResult } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit-html/directives/if-defined.js';
|
|
2
3
|
import {
|
|
3
4
|
AirtimeCreatedEvent,
|
|
4
5
|
AirtimeTransferredEvent,
|
|
@@ -15,7 +16,6 @@ import {
|
|
|
15
16
|
URNsChangedEvent
|
|
16
17
|
} from '../events';
|
|
17
18
|
import { getLanguageName } from '../languages';
|
|
18
|
-
import { oxfordFn } from '../utils';
|
|
19
19
|
|
|
20
20
|
export enum Events {
|
|
21
21
|
AIRTIME_CREATED = 'airtime_created',
|
|
@@ -26,8 +26,12 @@ export enum Events {
|
|
|
26
26
|
CALL_RECEIVED = 'call_received',
|
|
27
27
|
CHAT_STARTED = 'chat_started',
|
|
28
28
|
CONTACT_FIELD_CHANGED = 'contact_field_changed',
|
|
29
|
+
// ephemeral, updates contact state instead of rendering as history
|
|
30
|
+
CONTACT_FLOW_CHANGED = 'contact_flow_changed',
|
|
29
31
|
CONTACT_GROUPS_CHANGED = 'contact_groups_changed',
|
|
30
32
|
CONTACT_LANGUAGE_CHANGED = 'contact_language_changed',
|
|
33
|
+
// ephemeral, updates contact state instead of rendering as history
|
|
34
|
+
CONTACT_LAST_SEEN_CHANGED = 'contact_last_seen_changed',
|
|
31
35
|
CONTACT_NAME_CHANGED = 'contact_name_changed',
|
|
32
36
|
CONTACT_STATUS_CHANGED = 'contact_status_changed',
|
|
33
37
|
CONTACT_URNS_CHANGED = 'contact_urns_changed',
|
|
@@ -67,87 +71,157 @@ export enum Events {
|
|
|
67
71
|
* rendered inline as a plain pill. Single source of truth for the
|
|
68
72
|
* "entity pill in chat history" look — inline margin keeps wrapping
|
|
69
73
|
* airy, and inline style works regardless of host-page Tailwind reach.
|
|
74
|
+
*
|
|
75
|
+
* Pills carry no native `title` — hover detail (verb, actor, time)
|
|
76
|
+
* lives in the rich tooltip renderEvent wraps around each event.
|
|
70
77
|
*/
|
|
71
78
|
const renderEntityPill = (
|
|
72
79
|
pillType: string,
|
|
73
80
|
name: string,
|
|
74
|
-
opts: {
|
|
81
|
+
opts: {
|
|
82
|
+
href?: string;
|
|
83
|
+
icon?: string;
|
|
84
|
+
prefixIcon?: string;
|
|
85
|
+
} = {}
|
|
75
86
|
): TemplateResult => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// max-width lets a long name (flows especially) ellipsize inside the
|
|
88
|
+
// pill instead of blowing out the event line. The wrapping anchor
|
|
89
|
+
// needs the same cap plus min-width:0 — as a flex item it would
|
|
90
|
+
// otherwise refuse to shrink below the pill's full content size,
|
|
91
|
+
// defeating the ellipsis. --label-min-width keeps a couple of
|
|
92
|
+
// characters visible when a pill is squeezed here, so it never
|
|
93
|
+
// collapses to bare chrome (icon + ellipsis) in a tight event line.
|
|
94
|
+
const pill = html`<temba-label
|
|
95
|
+
type=${pillType}
|
|
96
|
+
icon=${ifDefined(opts.icon)}
|
|
97
|
+
prefix-icon=${ifDefined(opts.prefixIcon)}
|
|
98
|
+
?clickable=${!!opts.href}
|
|
99
|
+
style="margin: 1px 2px; vertical-align: middle; max-width: 100%; --label-min-width: 2em;"
|
|
100
|
+
>${name}</temba-label
|
|
101
|
+
>`;
|
|
102
|
+
// text-decoration: none matters here - flex blockifies the label,
|
|
103
|
+
// and unlike the old inline layout an anchor's underline propagates
|
|
104
|
+
// into block-level children, underlining the pill text.
|
|
90
105
|
return opts.href
|
|
91
106
|
? html`<a
|
|
92
107
|
href=${opts.href}
|
|
93
108
|
onclick="goto(event, this)"
|
|
94
|
-
style="vertical-align: middle;"
|
|
109
|
+
style="vertical-align: middle; display: inline-flex; min-width: 0; max-width: 100%; text-decoration: none;"
|
|
95
110
|
>${pill}</a
|
|
96
111
|
>`
|
|
97
112
|
: pill;
|
|
98
113
|
};
|
|
99
114
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Renders a single group addition or removal as a self-contained
|
|
117
|
+
* pill linking to the group, following the fixed pill convention:
|
|
118
|
+
* the group name on the left, the bold verb on the right — " Drivers
|
|
119
|
+
* | Added " / " Farmers | Removed " — with the red removal variant
|
|
120
|
+
* reinforcing removals (it has no default icon, so the group icon is
|
|
121
|
+
* set explicitly there).
|
|
122
|
+
*/
|
|
123
|
+
const groupPill = (group: any, removed = false): TemplateResult =>
|
|
124
|
+
attributePill(group.name, removed ? 'Removed' : 'Added', {
|
|
125
|
+
type: removed ? 'removed' : 'group',
|
|
126
|
+
icon: removed ? 'group' : undefined,
|
|
127
|
+
href: `/contact/group/${group.uuid}/`
|
|
103
128
|
});
|
|
104
129
|
|
|
105
|
-
const flowPill = (flow: any) =>
|
|
130
|
+
const flowPill = (flow: any, opts: { prefixIcon?: string } = {}) =>
|
|
106
131
|
renderEntityPill('flow', flow.name, {
|
|
107
|
-
href: `/flow/editor/${flow.uuid}
|
|
132
|
+
href: `/flow/editor/${flow.uuid}/`,
|
|
133
|
+
...opts
|
|
108
134
|
});
|
|
109
135
|
|
|
110
|
-
const fieldPill = (field: any) => renderEntityPill('field', field.name);
|
|
111
|
-
|
|
112
136
|
const topicPill = (topic: any) =>
|
|
113
137
|
renderEntityPill('topic', topic.name, {
|
|
114
138
|
href: `/ticket/${topic.uuid}/open/`
|
|
115
139
|
});
|
|
116
140
|
|
|
117
141
|
/**
|
|
118
|
-
* Renders a
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
142
|
+
* Renders a ticket lifecycle change as a self-contained pill linking
|
|
143
|
+
* to the ticket, following the fixed pill convention: label on the
|
|
144
|
+
* left (a user or "Ticket") and the bold verb on the right, e.g.
|
|
145
|
+
* " Sally Sue | Assigned ". Uses the topic (orange) variant so
|
|
146
|
+
* everything ticket-related shares one color, with the agent icon
|
|
147
|
+
* marking these as the ticket itself rather than a topic change.
|
|
148
|
+
* The acting user is named (with an avatar) in the rich hover
|
|
149
|
+
* tooltip renderEvent attaches.
|
|
124
150
|
*/
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
151
|
+
const ticketPill = (opts: {
|
|
152
|
+
uuid: string;
|
|
153
|
+
name?: string;
|
|
154
|
+
folder?: string;
|
|
155
|
+
verb: string;
|
|
156
|
+
}) =>
|
|
157
|
+
attributePill(opts.name || 'Ticket', opts.verb, {
|
|
158
|
+
type: 'topic',
|
|
159
|
+
icon: 'tickets',
|
|
160
|
+
href: `/ticket/all/${opts.folder || 'open'}/${opts.uuid}/`
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const getUserName = (user: any): string =>
|
|
164
|
+
user.name || [user.first_name, user.last_name].filter(Boolean).join(' ');
|
|
165
|
+
|
|
166
|
+
// thin currentColor rule separating the attribute name from its value
|
|
167
|
+
// inside an attribute pill — picks up each variant's text color
|
|
168
|
+
const attributeDividerStyle =
|
|
169
|
+
'display: inline-block; width: 1px; height: 11px; background: currentColor; opacity: 0.3; margin: 0 6px; vertical-align: -2px;';
|
|
170
|
+
|
|
171
|
+
// either half of an attribute pill — capped so a long name or value
|
|
172
|
+
// can't stretch the pill across the chat
|
|
173
|
+
const attributeSideStyle = (maxWidth = '18em') =>
|
|
174
|
+
`display: inline-block; max-width: ${maxWidth}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;`;
|
|
135
175
|
|
|
136
176
|
/**
|
|
137
|
-
* Renders a
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
177
|
+
* Renders a name/value pair as a single self-contained pill: the
|
|
178
|
+
* name, a thin divider, then the bolded value — e.g. " Age | 43 " or
|
|
179
|
+
* " Update Info | Completed ". The layout is a fixed convention
|
|
180
|
+
* across every event pill: label on the left, value on the right,
|
|
181
|
+
* bold on the right. A null value renders as an italic "cleared".
|
|
182
|
+
* Both sides truncate — the full text lives in the rich hover
|
|
183
|
+
* tooltip renderEvent attaches, so no native titles here.
|
|
184
|
+
* TemplateResult values (e.g. a temba-date) are passed through
|
|
185
|
+
* bolded. With `href` the pill wraps in a navigation anchor, same
|
|
186
|
+
* as renderEntityPill.
|
|
143
187
|
*/
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
188
|
+
const attributePill = (
|
|
189
|
+
name: string,
|
|
190
|
+
value: string | TemplateResult | null,
|
|
191
|
+
opts: {
|
|
192
|
+
type?: string;
|
|
193
|
+
icon?: string;
|
|
194
|
+
href?: string;
|
|
195
|
+
nameMaxWidth?: string;
|
|
196
|
+
} = {}
|
|
197
|
+
): TemplateResult => {
|
|
198
|
+
const valueContent =
|
|
199
|
+
value === null
|
|
200
|
+
? html`<span style="font-style: italic; opacity: 0.7;">cleared</span>`
|
|
201
|
+
: typeof value === 'string'
|
|
202
|
+
? html`<span style="${attributeSideStyle()} font-weight: 600;"
|
|
203
|
+
>${value}</span
|
|
204
|
+
>`
|
|
205
|
+
: html`<span style="font-weight: 600;">${value}</span>`;
|
|
206
|
+
const nameContent = html`<span style=${attributeSideStyle(opts.nameMaxWidth)}
|
|
207
|
+
>${name}</span
|
|
208
|
+
>`;
|
|
209
|
+
const pill = html`<temba-label
|
|
210
|
+
type=${opts.type || 'neutral'}
|
|
211
|
+
icon=${ifDefined(opts.icon)}
|
|
212
|
+
?clickable=${!!opts.href}
|
|
213
|
+
style="margin: 1px 2px; vertical-align: middle;"
|
|
214
|
+
>${nameContent}<span style=${attributeDividerStyle}></span
|
|
215
|
+
>${valueContent}</temba-label
|
|
150
216
|
>`;
|
|
217
|
+
return opts.href
|
|
218
|
+
? html`<a
|
|
219
|
+
href=${opts.href}
|
|
220
|
+
onclick="goto(event, this)"
|
|
221
|
+
style="vertical-align: middle;"
|
|
222
|
+
>${pill}</a
|
|
223
|
+
>`
|
|
224
|
+
: pill;
|
|
151
225
|
};
|
|
152
226
|
|
|
153
227
|
/**
|
|
@@ -164,56 +238,50 @@ const valueText = (value: string | number) => {
|
|
|
164
238
|
const eventLineStyle =
|
|
165
239
|
'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px; min-height: 24px;';
|
|
166
240
|
|
|
167
|
-
const renderInfoList = (
|
|
168
|
-
singular: string,
|
|
169
|
-
plural: string,
|
|
170
|
-
items: any[]
|
|
171
|
-
): TemplateResult => {
|
|
172
|
-
if (items.length === 1) {
|
|
173
|
-
return html`<div style=${eventLineStyle}>
|
|
174
|
-
${singular} ${groupPill(items[0])}
|
|
175
|
-
</div>`;
|
|
176
|
-
}
|
|
177
|
-
if (items.length === 2) {
|
|
178
|
-
return html`<div style=${eventLineStyle}>
|
|
179
|
-
${plural} ${groupPill(items[0])} and ${groupPill(items[1])}
|
|
180
|
-
</div>`;
|
|
181
|
-
}
|
|
182
|
-
// No commas between pills — the flex `gap` on eventLineStyle
|
|
183
|
-
// already provides visual separation, and a pill list reads as a
|
|
184
|
-
// single "set" rather than a sentence.
|
|
185
|
-
const middle = items.slice(0, -1).map((item) => groupPill(item));
|
|
186
|
-
const last = items[items.length - 1];
|
|
187
|
-
return html`<div style=${eventLineStyle}>
|
|
188
|
-
${plural} ${middle} and ${groupPill(last)}
|
|
189
|
-
</div>`;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
241
|
export const renderRunEvent = (event: RunEvent): TemplateResult => {
|
|
242
|
+
// follows the fixed pill convention: label (flow name) on the
|
|
243
|
+
// left, bold value (the run state) on the right — " Update Info |
|
|
244
|
+
// Completed " — matching how field pills read. Runs cut short
|
|
245
|
+
// (interrupted / expired) use the red removal variant, matching
|
|
246
|
+
// group removals; that variant has no default icon so the flow
|
|
247
|
+
// icon is set explicitly.
|
|
193
248
|
let verb = 'Started';
|
|
249
|
+
let cutShort = false;
|
|
194
250
|
if (event.type === Events.RUN_ENDED) {
|
|
195
251
|
if (event.status === 'completed') {
|
|
196
252
|
verb = 'Completed';
|
|
197
253
|
} else if (event.status === 'expired') {
|
|
198
|
-
verb = 'Expired
|
|
254
|
+
verb = 'Expired';
|
|
255
|
+
cutShort = true;
|
|
199
256
|
} else {
|
|
200
257
|
verb = 'Interrupted';
|
|
258
|
+
cutShort = true;
|
|
201
259
|
}
|
|
202
260
|
}
|
|
203
261
|
|
|
204
262
|
return html`<div style=${eventLineStyle}>
|
|
205
|
-
${
|
|
263
|
+
${attributePill(event.flow.name, verb, {
|
|
264
|
+
type: cutShort ? 'removed' : 'flow',
|
|
265
|
+
icon: cutShort ? 'flow' : undefined,
|
|
266
|
+
href: `/flow/editor/${event.flow.uuid}/`,
|
|
267
|
+
// flow names run long — cap them harder than other pill labels
|
|
268
|
+
nameMaxWidth: '160px'
|
|
269
|
+
})}
|
|
206
270
|
</div>`;
|
|
207
271
|
};
|
|
208
272
|
|
|
209
273
|
export const renderChatStartedEvent = (
|
|
210
274
|
event: ChatStartedEvent
|
|
211
275
|
): TemplateResult => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
276
|
+
return html`<div style=${eventLineStyle}>
|
|
277
|
+
${renderEntityPill(
|
|
278
|
+
'neutral',
|
|
279
|
+
event.params ? 'Chat referral' : 'Chat started',
|
|
280
|
+
{
|
|
281
|
+
icon: 'message'
|
|
282
|
+
}
|
|
283
|
+
)}
|
|
284
|
+
</div>`;
|
|
217
285
|
};
|
|
218
286
|
|
|
219
287
|
export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
|
|
@@ -221,35 +289,44 @@ export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
|
|
|
221
289
|
// cleared field — backfill / reset payloads sometimes arrive as
|
|
222
290
|
// `value: { text: '' }`, which would otherwise render with an
|
|
223
291
|
// empty value pill.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
292
|
+
const value = event.value;
|
|
293
|
+
let display: string | TemplateResult | null = null;
|
|
294
|
+
if (value && value.text) {
|
|
295
|
+
// date values carry a typed datetime alongside the raw text — show
|
|
296
|
+
// those through temba-date for short, locale-aware formatting
|
|
297
|
+
display = value.datetime
|
|
298
|
+
? html`<temba-date value=${value.datetime} display="day"></temba-date>`
|
|
299
|
+
: value.text;
|
|
300
|
+
}
|
|
301
|
+
return html`<div style=${eventLineStyle}>
|
|
302
|
+
${attributePill(event.field.name, display, { type: 'field' })}
|
|
303
|
+
</div>`;
|
|
231
304
|
};
|
|
232
305
|
|
|
233
306
|
export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
|
|
234
|
-
if (!event.name) {
|
|
235
|
-
return html`<div style=${eventLineStyle}>Cleared name</div>`;
|
|
236
|
-
}
|
|
237
307
|
return html`<div style=${eventLineStyle}>
|
|
238
|
-
|
|
308
|
+
${attributePill('Name', event.name || null, { icon: 'contact' })}
|
|
239
309
|
</div>`;
|
|
240
310
|
};
|
|
241
311
|
|
|
312
|
+
// strip the scheme and query string from a raw URN for display
|
|
313
|
+
// (e.g. "tel:+1234?channel=x" → "+1234"); a malformed URN without a
|
|
314
|
+
// scheme separator passes through as-is rather than throwing
|
|
315
|
+
const urnDisplayValue = (urn: string): string => {
|
|
316
|
+
const parts = urn.split(':');
|
|
317
|
+
return (parts.length > 1 ? parts[1] : urn).split('?')[0];
|
|
318
|
+
};
|
|
319
|
+
|
|
242
320
|
export const renderContactURNsChanged = (
|
|
243
321
|
event: URNsChangedEvent
|
|
244
322
|
): TemplateResult => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
323
|
+
// URNs are replaced as a set, so the whole set shares one pill —
|
|
324
|
+
// the value truncates past 18em with the full list on hover
|
|
325
|
+
const urns = (event.urns || []).map(urnDisplayValue);
|
|
248
326
|
return html`<div style=${eventLineStyle}>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)}
|
|
327
|
+
${attributePill('URNs', urns.length ? urns.join(', ') : null, {
|
|
328
|
+
icon: 'at-sign'
|
|
329
|
+
})}
|
|
253
330
|
</div>`;
|
|
254
331
|
};
|
|
255
332
|
|
|
@@ -273,102 +350,215 @@ export const renderTicketAction = (
|
|
|
273
350
|
}
|
|
274
351
|
|
|
275
352
|
// closed → ticket is in the closed folder now; reopened → it's
|
|
276
|
-
// back in open
|
|
277
|
-
//
|
|
353
|
+
// back in open, so the pill links to wherever the ticket actually
|
|
354
|
+
// lives. Tickets are always closed by someone, so that user's name
|
|
355
|
+
// is the pill's label ("Adam Ant | Closed") rather than a generic
|
|
356
|
+
// "Ticket" — with the generic fallback kept for defensive safety.
|
|
278
357
|
const folder = action === 'closed' ? 'closed' : 'open';
|
|
279
|
-
const
|
|
280
|
-
return
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
358
|
+
const verb = action === 'closed' ? 'Closed' : 'Reopened';
|
|
359
|
+
return html`<div style=${eventLineStyle}>
|
|
360
|
+
${ticketPill({
|
|
361
|
+
uuid: ticketUUID,
|
|
362
|
+
name:
|
|
363
|
+
action === 'closed' && event._user
|
|
364
|
+
? getUserName(event._user)
|
|
365
|
+
: undefined,
|
|
366
|
+
folder,
|
|
367
|
+
verb
|
|
368
|
+
})}
|
|
369
|
+
</div>`;
|
|
287
370
|
};
|
|
288
371
|
|
|
289
372
|
export const renderTicketAssigneeChanged = (
|
|
290
373
|
event: TicketEvent
|
|
291
374
|
): TemplateResult => {
|
|
292
375
|
const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// user link + verb, rather than "<user> assigned to <same user>".
|
|
305
|
-
// Match on uuid when present, falling back to email — depending
|
|
306
|
-
// on the API surface a user payload may carry one or the other.
|
|
307
|
-
const sameUser =
|
|
308
|
-
(event._user.uuid && event._user.uuid === event.assignee.uuid) ||
|
|
309
|
-
(event._user.email && event._user.email === event.assignee.email);
|
|
310
|
-
if (sameUser) {
|
|
311
|
-
return html`<div style=${eventLineStyle}>
|
|
312
|
-
${userLink(event._user)} took this ${ticketLink}
|
|
313
|
-
</div>`;
|
|
314
|
-
}
|
|
315
|
-
return html`<div style=${eventLineStyle}>
|
|
316
|
-
${userLink(event._user)} assigned this ${ticketLink} to
|
|
317
|
-
${userLink(event.assignee)}
|
|
318
|
-
</div>`;
|
|
319
|
-
} else {
|
|
320
|
-
return html`<div style=${eventLineStyle}>
|
|
321
|
-
${userLink(event._user)} unassigned this ${ticketLink}
|
|
322
|
-
</div>`;
|
|
323
|
-
}
|
|
324
|
-
} else {
|
|
325
|
-
if (event.assignee) {
|
|
326
|
-
return html`<div style=${eventLineStyle}>
|
|
327
|
-
${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
|
|
328
|
-
</div>`;
|
|
329
|
-
} else {
|
|
330
|
-
return html`<div style=${eventLineStyle}>
|
|
331
|
-
${ticketLinkCapitalized} was unassigned
|
|
332
|
-
</div>`;
|
|
333
|
-
}
|
|
376
|
+
if (event.assignee) {
|
|
377
|
+
// the assignee is the pill's label — who owns the ticket now is
|
|
378
|
+
// the fact that matters. Who did the assigning lives in the
|
|
379
|
+
// hover tooltip.
|
|
380
|
+
return html`<div style=${eventLineStyle}>
|
|
381
|
+
${ticketPill({
|
|
382
|
+
uuid: ticketUUID,
|
|
383
|
+
name: getUserName(event.assignee),
|
|
384
|
+
verb: 'Assigned'
|
|
385
|
+
})}
|
|
386
|
+
</div>`;
|
|
334
387
|
}
|
|
388
|
+
return html`<div style=${eventLineStyle}>
|
|
389
|
+
${ticketPill({
|
|
390
|
+
uuid: ticketUUID,
|
|
391
|
+
verb: 'Unassigned'
|
|
392
|
+
})}
|
|
393
|
+
</div>`;
|
|
335
394
|
};
|
|
336
395
|
|
|
337
396
|
export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
397
|
+
// the opened pill leads with the topic behind its topic icon, then
|
|
398
|
+
// the assignee behind an inline agent icon when the event carries
|
|
399
|
+
// one, each segment split by the standard divider before the bold
|
|
400
|
+
// verb — e.g. " (topic) Billing | (agent) Sally Sue | Opened ".
|
|
401
|
+
// Without a topic the assignee (behind the label-level agent icon)
|
|
402
|
+
// is the whole label, and with neither it falls back to the
|
|
403
|
+
// generic ticket pill. ticket_opened carries its assignee inside
|
|
404
|
+
// the ticket envelope (unlike ticket_assignee_changed, where it's
|
|
405
|
+
// on the event itself).
|
|
343
406
|
const topic = event.ticket.topic;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
407
|
+
const assignee = event.ticket.assignee || event.assignee;
|
|
408
|
+
|
|
409
|
+
if (!topic && !assignee) {
|
|
410
|
+
return html`<div style=${eventLineStyle}>
|
|
411
|
+
${ticketPill({
|
|
412
|
+
uuid: event.ticket.uuid,
|
|
413
|
+
verb: 'Opened'
|
|
414
|
+
})}
|
|
415
|
+
</div>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// an inline agent icon marking the assignee segment when the topic
|
|
419
|
+
// occupies the pill's leading (label-level) icon slot — aligned to
|
|
420
|
+
// match the divider
|
|
421
|
+
const inlineAgentIcon = html`<temba-icon
|
|
422
|
+
name="tickets"
|
|
423
|
+
style="display: inline-block; vertical-align: -2px; margin-right: 4px;"
|
|
424
|
+
></temba-icon>`;
|
|
425
|
+
|
|
426
|
+
const pill = html`<temba-label
|
|
427
|
+
type="topic"
|
|
428
|
+
icon=${topic ? 'topic' : 'tickets'}
|
|
429
|
+
clickable
|
|
430
|
+
style="margin: 1px 2px; vertical-align: middle;"
|
|
431
|
+
>${topic
|
|
432
|
+
? html`<span style=${attributeSideStyle()}>${topic.name}</span>`
|
|
433
|
+
: null}${assignee
|
|
434
|
+
? html`${topic
|
|
435
|
+
? html`<span style=${attributeDividerStyle}></span>`
|
|
436
|
+
: null}${inlineAgentIcon}<span style=${attributeSideStyle()}
|
|
437
|
+
>${getUserName(assignee)}</span
|
|
438
|
+
>`
|
|
439
|
+
: null}<span style=${attributeDividerStyle}></span
|
|
440
|
+
><span style="font-weight: 600;">Opened</span></temba-label
|
|
441
|
+
>`;
|
|
442
|
+
|
|
443
|
+
return html`<div style=${eventLineStyle}>
|
|
444
|
+
<a
|
|
445
|
+
href=${`/ticket/all/open/${event.ticket.uuid}/`}
|
|
446
|
+
onclick="goto(event, this)"
|
|
447
|
+
style="vertical-align: middle;"
|
|
448
|
+
>${pill}</a
|
|
449
|
+
>
|
|
450
|
+
</div>`;
|
|
352
451
|
};
|
|
353
452
|
|
|
354
453
|
export const renderContactGroupsEvent = (
|
|
355
|
-
event: ContactGroupsEvent
|
|
454
|
+
event: ContactGroupsEvent,
|
|
455
|
+
withTooltips = false
|
|
456
|
+
): TemplateResult | null => {
|
|
457
|
+
// a group event can carry several groups — with tooltips on (the
|
|
458
|
+
// contact chat), each pill gets its own tip anchored to the hovered
|
|
459
|
+
// pill, so the tips wrap per-pill here rather than renderEvent
|
|
460
|
+
// wrapping the whole line
|
|
461
|
+
const wrap = (pill: TemplateResult): TemplateResult =>
|
|
462
|
+
withTooltips
|
|
463
|
+
? html`<temba-tip
|
|
464
|
+
style="display: inline-block; max-width: 100%;"
|
|
465
|
+
position="top"
|
|
466
|
+
distance="4"
|
|
467
|
+
arrow-size="18"
|
|
468
|
+
interactive
|
|
469
|
+
.content=${renderEventTooltip(event)}
|
|
470
|
+
>${pill}</temba-tip
|
|
471
|
+
>`
|
|
472
|
+
: pill;
|
|
473
|
+
|
|
474
|
+
const pills = [
|
|
475
|
+
...(event.groups_added || []).map((group) => wrap(groupPill(group))),
|
|
476
|
+
...(event.groups_removed || []).map((group) => wrap(groupPill(group, true)))
|
|
477
|
+
];
|
|
478
|
+
if (pills.length === 0) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
return html`<div style=${eventLineStyle}>${pills}</div>`;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Renders a run of informational events as a single collapsed
|
|
486
|
+
* summary pill — the unique set of icons for what's inside followed
|
|
487
|
+
* by the total event count, e.g. " ⌄ ⚡👤 12 ". The pill is
|
|
488
|
+
* stateless; the chat wires `onExpand` and swaps in the detailed
|
|
489
|
+
* pills when clicked.
|
|
490
|
+
*/
|
|
491
|
+
export const renderEventSummary = (
|
|
492
|
+
events: any[],
|
|
493
|
+
onExpand: () => void
|
|
356
494
|
): TemplateResult => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
495
|
+
// one icon per kind of thing inside, deduped, in order of first
|
|
496
|
+
// appearance
|
|
497
|
+
const icons: string[] = [];
|
|
498
|
+
const addIcon = (icon: string) => {
|
|
499
|
+
if (!icons.includes(icon)) {
|
|
500
|
+
icons.push(icon);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
for (const event of events) {
|
|
504
|
+
switch (event.type) {
|
|
505
|
+
case Events.RUN_STARTED:
|
|
506
|
+
case Events.RUN_ENDED:
|
|
507
|
+
case Events.FLOW_ENTERED:
|
|
508
|
+
case Events.SESSION_TRIGGERED:
|
|
509
|
+
addIcon('flow');
|
|
510
|
+
break;
|
|
511
|
+
case Events.CONTACT_GROUPS_CHANGED:
|
|
512
|
+
addIcon('group');
|
|
513
|
+
break;
|
|
514
|
+
case Events.CONTACT_FIELD_CHANGED:
|
|
515
|
+
case Events.CONTACT_NAME_CHANGED:
|
|
516
|
+
case Events.CONTACT_LANGUAGE_CHANGED:
|
|
517
|
+
case Events.CONTACT_STATUS_CHANGED:
|
|
518
|
+
case Events.CONTACT_URNS_CHANGED:
|
|
519
|
+
addIcon('fields');
|
|
520
|
+
break;
|
|
521
|
+
case Events.TICKET_OPENED:
|
|
522
|
+
case Events.TICKET_CLOSED:
|
|
523
|
+
case Events.TICKET_REOPENED:
|
|
524
|
+
case Events.TICKET_ASSIGNEE_CHANGED:
|
|
525
|
+
case Events.TICKET_TOPIC_CHANGED:
|
|
526
|
+
addIcon('tickets');
|
|
527
|
+
break;
|
|
528
|
+
default:
|
|
529
|
+
addIcon('event');
|
|
530
|
+
}
|
|
361
531
|
}
|
|
532
|
+
|
|
533
|
+
return html`<temba-label
|
|
534
|
+
type="neutral"
|
|
535
|
+
icon="down"
|
|
536
|
+
title="Show details"
|
|
537
|
+
clickable
|
|
538
|
+
style="margin: 1px 2px; vertical-align: middle;"
|
|
539
|
+
@click=${onExpand}
|
|
540
|
+
>${icons.map(
|
|
541
|
+
(icon) =>
|
|
542
|
+
html`<temba-icon
|
|
543
|
+
name=${icon}
|
|
544
|
+
style="display: inline-block; vertical-align: -2px; margin-right: 3px;"
|
|
545
|
+
></temba-icon>`
|
|
546
|
+
)}<span style="font-weight: 600; margin-left: 2px;"
|
|
547
|
+
>${events.length}</span
|
|
548
|
+
></temba-label
|
|
549
|
+
>`;
|
|
362
550
|
};
|
|
363
551
|
|
|
364
552
|
export const renderAirtimeTransferredEvent = (
|
|
365
553
|
event: AirtimeTransferredEvent
|
|
366
554
|
): TemplateResult => {
|
|
367
|
-
|
|
368
|
-
return html`<div>Airtime transfer failed</div>`;
|
|
369
|
-
}
|
|
555
|
+
const failed = parseFloat(event.amount) === 0;
|
|
370
556
|
return html`<div style=${eventLineStyle}>
|
|
371
|
-
|
|
557
|
+
${failed
|
|
558
|
+
? attributePill('Airtime', 'failed', { icon: 'airtime' })
|
|
559
|
+
: attributePill('Airtime', `${event.amount} ${event.currency}`, {
|
|
560
|
+
icon: 'airtime'
|
|
561
|
+
})}
|
|
372
562
|
</div>`;
|
|
373
563
|
};
|
|
374
564
|
|
|
@@ -376,67 +566,83 @@ export const renderAirtimeCreatedEvent = (
|
|
|
376
566
|
event: AirtimeCreatedEvent
|
|
377
567
|
): TemplateResult => {
|
|
378
568
|
const status = event._status?.status ?? 'created';
|
|
379
|
-
|
|
569
|
+
let value = `${event.amount} ${event.currency}`;
|
|
380
570
|
|
|
381
571
|
switch (status) {
|
|
382
572
|
case 'reversed':
|
|
383
|
-
|
|
573
|
+
value = 'reversed';
|
|
574
|
+
break;
|
|
384
575
|
case 'rejected':
|
|
385
576
|
case 'cancelled':
|
|
386
577
|
case 'declined':
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
return html`<div style=${eventLineStyle}>
|
|
390
|
-
Transferred ${amount} of airtime
|
|
391
|
-
</div>`;
|
|
392
|
-
default:
|
|
393
|
-
return html`<div style=${eventLineStyle}>
|
|
394
|
-
Sending ${amount} of airtime
|
|
395
|
-
</div>`;
|
|
578
|
+
value = 'failed';
|
|
579
|
+
break;
|
|
396
580
|
}
|
|
581
|
+
|
|
582
|
+
return html`<div style=${eventLineStyle}>
|
|
583
|
+
${attributePill('Airtime', value, { icon: 'airtime' })}
|
|
584
|
+
</div>`;
|
|
397
585
|
};
|
|
398
586
|
|
|
399
587
|
export const renderContactLanguageChangedEvent = (
|
|
400
588
|
event: ContactLanguageChangedEvent
|
|
401
589
|
): TemplateResult => {
|
|
402
|
-
|
|
403
|
-
return html`<div style=${eventLineStyle}>Cleared language</div>`;
|
|
404
|
-
}
|
|
590
|
+
const language = event.language ? getLanguageName(event.language) : null;
|
|
405
591
|
return html`<div style=${eventLineStyle}>
|
|
406
|
-
Language
|
|
592
|
+
${attributePill('Language', language, { icon: 'language' })}
|
|
407
593
|
</div>`;
|
|
408
594
|
};
|
|
409
595
|
|
|
410
596
|
export const renderContactStatusChangedEvent = (
|
|
411
597
|
event: ContactStatusChangedEvent
|
|
412
598
|
): TemplateResult => {
|
|
413
|
-
if (!event.status) {
|
|
414
|
-
return html`<div style=${eventLineStyle}>Cleared status</div>`;
|
|
415
|
-
}
|
|
416
599
|
return html`<div style=${eventLineStyle}>
|
|
417
|
-
|
|
600
|
+
${attributePill('Status', event.status || null)}
|
|
418
601
|
</div>`;
|
|
419
602
|
};
|
|
420
603
|
|
|
421
|
-
export const renderCallEvent = (event: CallEvent): TemplateResult => {
|
|
604
|
+
export const renderCallEvent = (event: CallEvent): TemplateResult | null => {
|
|
605
|
+
let label: string = null;
|
|
606
|
+
let icon: string = null;
|
|
422
607
|
if (event.type === Events.CALL_CREATED) {
|
|
423
|
-
|
|
608
|
+
label = 'Call started';
|
|
609
|
+
icon = 'call';
|
|
424
610
|
} else if (event.type === Events.CALL_MISSED) {
|
|
425
|
-
|
|
611
|
+
label = 'Call missed';
|
|
612
|
+
icon = 'missed_call';
|
|
426
613
|
} else if (event.type === Events.CALL_RECEIVED) {
|
|
427
|
-
|
|
614
|
+
label = 'Call answered';
|
|
615
|
+
icon = 'incoming_call';
|
|
428
616
|
}
|
|
617
|
+
if (!label) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
return html`<div style=${eventLineStyle}>
|
|
621
|
+
${renderEntityPill('neutral', label, { icon })}
|
|
622
|
+
</div>`;
|
|
429
623
|
};
|
|
430
624
|
|
|
431
625
|
export const renderOptInEvent = (event: OptInEvent): TemplateResult => {
|
|
626
|
+
// opt-out gets the red removal treatment to mirror group removal;
|
|
627
|
+
// the hover tooltip and icon carry the verb in all three cases
|
|
432
628
|
if (event.type === Events.OPTIN_REQUESTED) {
|
|
433
|
-
return html`<div>
|
|
434
|
-
|
|
629
|
+
return html`<div style=${eventLineStyle}>
|
|
630
|
+
${renderEntityPill('neutral', event.optin.name, {
|
|
631
|
+
icon: 'optin_requested'
|
|
632
|
+
})}
|
|
435
633
|
</div>`;
|
|
436
634
|
} else if (event.type === Events.OPTIN_STARTED) {
|
|
437
|
-
return html`<div
|
|
635
|
+
return html`<div style=${eventLineStyle}>
|
|
636
|
+
${renderEntityPill('neutral', event.optin.name, {
|
|
637
|
+
icon: 'optin'
|
|
638
|
+
})}
|
|
639
|
+
</div>`;
|
|
438
640
|
} else if (event.type === Events.OPTIN_STOPPED) {
|
|
439
|
-
return html`<div
|
|
641
|
+
return html`<div style=${eventLineStyle}>
|
|
642
|
+
${renderEntityPill('removed', event.optin.name, {
|
|
643
|
+
icon: 'optout'
|
|
644
|
+
})}
|
|
645
|
+
</div>`;
|
|
440
646
|
}
|
|
441
647
|
};
|
|
442
648
|
|
|
@@ -469,85 +675,65 @@ export const renderDiagnosticEvent = (
|
|
|
469
675
|
return null;
|
|
470
676
|
};
|
|
471
677
|
|
|
472
|
-
export const renderRunResultChanged = (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (val.length > MAX_LEN) {
|
|
480
|
-
const displayVal = val.substring(0, MAX_LEN) + '...';
|
|
481
|
-
return html`<div>
|
|
482
|
-
Set result <strong>${event.name}</strong> to "<span
|
|
483
|
-
title="${val}"
|
|
484
|
-
style="cursor: help; border-bottom: 1px dotted #999;"
|
|
485
|
-
>${displayVal}</span
|
|
486
|
-
>"
|
|
487
|
-
</div>`;
|
|
488
|
-
}
|
|
489
|
-
return html`<div>Set result <strong>${event.name}</strong> to "${val}"</div>`;
|
|
678
|
+
export const renderRunResultChanged = (event: any): TemplateResult | null => {
|
|
679
|
+
// attributePill truncates long values; the hover tooltip carries
|
|
680
|
+
// the full text
|
|
681
|
+
return html`<div style=${eventLineStyle}>
|
|
682
|
+
${attributePill(event.name, String(event.value))}
|
|
683
|
+
</div>`;
|
|
490
684
|
};
|
|
491
685
|
|
|
492
686
|
export const renderInputLabelsAdded = (event: any): TemplateResult | null => {
|
|
493
687
|
const labels = event.labels || [];
|
|
494
|
-
if (labels.length
|
|
495
|
-
|
|
496
|
-
if (labelList.length === 1) {
|
|
497
|
-
return html`<div>
|
|
498
|
-
Message labeled with <strong>${labelList[0]}</strong>
|
|
499
|
-
</div>`;
|
|
500
|
-
} else {
|
|
501
|
-
const last = labelList.pop();
|
|
502
|
-
return html`<div>
|
|
503
|
-
Message labeled with
|
|
504
|
-
${labelList.map(
|
|
505
|
-
(name: string, index: number) =>
|
|
506
|
-
html`<strong>${name}</strong>${index < labelList.length - 1
|
|
507
|
-
? ', '
|
|
508
|
-
: ''}`
|
|
509
|
-
)}
|
|
510
|
-
and <strong>${last}</strong>
|
|
511
|
-
</div>`;
|
|
512
|
-
}
|
|
688
|
+
if (labels.length === 0) {
|
|
689
|
+
return null;
|
|
513
690
|
}
|
|
514
|
-
return
|
|
691
|
+
return html`<div style=${eventLineStyle}>
|
|
692
|
+
${labels.map((l: any) => renderEntityPill('label', l.name))}
|
|
693
|
+
</div>`;
|
|
515
694
|
};
|
|
516
695
|
|
|
517
696
|
export const renderEmailSent = (event: any): TemplateResult | null => {
|
|
518
697
|
const recipients = event.to || event.addresses || [];
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const recipientList = recipients.join(', ');
|
|
522
|
-
return html`<div>
|
|
523
|
-
Sent email to <strong>${recipientList}</strong> with subject "${subject}"
|
|
524
|
-
</div>`;
|
|
698
|
+
if (recipients.length === 0) {
|
|
699
|
+
return null;
|
|
525
700
|
}
|
|
526
|
-
return
|
|
701
|
+
return html`<div style=${eventLineStyle}>
|
|
702
|
+
${renderEntityPill('neutral', recipients.join(', '), {
|
|
703
|
+
icon: 'email'
|
|
704
|
+
})}
|
|
705
|
+
</div>`;
|
|
527
706
|
};
|
|
528
707
|
|
|
529
708
|
export const renderBroadcastCreated = (event: any): TemplateResult | null => {
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
709
|
+
const text = event.translations?.[event.base_language]?.text;
|
|
710
|
+
const maxLen = 50;
|
|
711
|
+
const display =
|
|
712
|
+
text && text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
|
|
713
|
+
return html`<div style=${eventLineStyle}>
|
|
714
|
+
${renderEntityPill('neutral', display || 'Broadcast', {
|
|
715
|
+
icon: 'broadcast'
|
|
716
|
+
})}
|
|
717
|
+
</div>`;
|
|
538
718
|
};
|
|
539
719
|
|
|
540
720
|
export const renderSessionTriggered = (event: any): TemplateResult | null => {
|
|
541
721
|
const flow = event.flow;
|
|
542
722
|
if (flow) {
|
|
543
|
-
return html`<div
|
|
723
|
+
return html`<div style=${eventLineStyle}>
|
|
724
|
+
${flowPill(flow, {
|
|
725
|
+
prefixIcon: 'contact'
|
|
726
|
+
})}
|
|
727
|
+
</div>`;
|
|
544
728
|
}
|
|
545
729
|
return null;
|
|
546
730
|
};
|
|
547
731
|
|
|
548
732
|
export const renderResthookCalled = (event: any): TemplateResult | null => {
|
|
549
|
-
return html`<div>
|
|
550
|
-
|
|
733
|
+
return html`<div style=${eventLineStyle}>
|
|
734
|
+
${renderEntityPill('neutral', event.resthook, {
|
|
735
|
+
icon: 'resthooks'
|
|
736
|
+
})}
|
|
551
737
|
</div>`;
|
|
552
738
|
};
|
|
553
739
|
|
|
@@ -557,15 +743,210 @@ export const renderWebhookCalled = (event: any): TemplateResult | null => {
|
|
|
557
743
|
event.url && event.url.length > maxLen
|
|
558
744
|
? event.url.slice(0, maxLen) + '...'
|
|
559
745
|
: event.url;
|
|
560
|
-
return html`<div
|
|
746
|
+
return html`<div style=${eventLineStyle}>
|
|
747
|
+
${renderEntityPill('neutral', displayUrl, {
|
|
748
|
+
icon: 'webhook'
|
|
749
|
+
})}
|
|
750
|
+
</div>`;
|
|
561
751
|
};
|
|
562
752
|
|
|
563
753
|
export const renderServiceCalled = (event: any): TemplateResult | null => {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
754
|
+
return html`<div style=${eventLineStyle}>
|
|
755
|
+
${renderEntityPill('neutral', event.service, {
|
|
756
|
+
icon: 'service'
|
|
757
|
+
})}
|
|
758
|
+
</div>`;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
// Rich hover tooltips
|
|
763
|
+
//
|
|
764
|
+
// Every inline event pill in the contact chat gets a temba-tip that
|
|
765
|
+
// only adds what the pill can't already show: the full text of a
|
|
766
|
+
// value the pill truncates (or the nicely formatted date and time
|
|
767
|
+
// behind a date field's short day), the acting user (avatar on the
|
|
768
|
+
// left, name beside the timestamp) and the detailed timestamp. A
|
|
769
|
+
// pill with nothing else to add still shows the timestamp alone.
|
|
770
|
+
// This replaces the native `title` attributes the pills used to
|
|
771
|
+
// carry.
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
const tooltipAvatar = (user: any): TemplateResult =>
|
|
775
|
+
html`<temba-user
|
|
776
|
+
name=${ifDefined(user.name)}
|
|
777
|
+
first_name=${ifDefined(user.first_name)}
|
|
778
|
+
last_name=${ifDefined(user.last_name)}
|
|
779
|
+
email=${ifDefined(user.email)}
|
|
780
|
+
avatar=${ifDefined(user.avatar)}
|
|
781
|
+
></temba-user>`;
|
|
782
|
+
|
|
783
|
+
// pill sides ellipsize at 18em — roughly this many characters at the
|
|
784
|
+
// pill font size. Values within the cap are already fully visible in
|
|
785
|
+
// the pill, so the tooltip only repeats ones longer than this.
|
|
786
|
+
const PILL_VALUE_CHARS = 32;
|
|
787
|
+
|
|
788
|
+
const fullValue = (
|
|
789
|
+
value: string | undefined,
|
|
790
|
+
visibleChars = PILL_VALUE_CHARS
|
|
791
|
+
): string[] => (value && value.length > visibleChars ? [value] : []);
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* The detail lines an event's tooltip adds beyond the pill itself —
|
|
795
|
+
* just the values the pill truncates. Most events add nothing (their
|
|
796
|
+
* pill already says it all); returns null for events that don't
|
|
797
|
+
* render as pills at all.
|
|
798
|
+
*/
|
|
799
|
+
const getEventTooltipLines = (
|
|
800
|
+
event: any
|
|
801
|
+
): (string | TemplateResult)[] | null => {
|
|
802
|
+
switch (event.type) {
|
|
803
|
+
case Events.CONTACT_FIELD_CHANGED: {
|
|
804
|
+
const value = event.value;
|
|
805
|
+
// date fields render as a short day in the pill — the tooltip
|
|
806
|
+
// carries the full date and time, formatted rather than a raw
|
|
807
|
+
// iso string
|
|
808
|
+
if (value?.datetime) {
|
|
809
|
+
return [
|
|
810
|
+
html`<temba-date
|
|
811
|
+
value=${value.datetime}
|
|
812
|
+
display="datetime"
|
|
813
|
+
></temba-date>`
|
|
814
|
+
];
|
|
815
|
+
}
|
|
816
|
+
return fullValue(value?.text);
|
|
817
|
+
}
|
|
818
|
+
case Events.CONTACT_NAME_CHANGED:
|
|
819
|
+
return fullValue(event.name);
|
|
820
|
+
case Events.CONTACT_URNS_CHANGED: {
|
|
821
|
+
const urns = (event.urns || []).map(urnDisplayValue);
|
|
822
|
+
return fullValue(urns.join(', '));
|
|
823
|
+
}
|
|
824
|
+
case Events.RUN_RESULT_CHANGED:
|
|
825
|
+
return fullValue(String(event.value));
|
|
826
|
+
case Events.EMAIL_CREATED:
|
|
827
|
+
case Events.EMAIL_SENT: {
|
|
828
|
+
const recipients = event.to || event.addresses || [];
|
|
829
|
+
// the subject is never in the pill; recipients only repeat when
|
|
830
|
+
// the pill truncates them
|
|
831
|
+
return [
|
|
832
|
+
...fullValue(recipients.join(', ')),
|
|
833
|
+
...(event.subject ? [event.subject] : [])
|
|
834
|
+
];
|
|
835
|
+
}
|
|
836
|
+
// these two pills cut their value at 50 characters themselves
|
|
837
|
+
case Events.BROADCAST_CREATED:
|
|
838
|
+
return fullValue(event.translations?.[event.base_language]?.text, 50);
|
|
839
|
+
case Events.WEBHOOK_CALLED:
|
|
840
|
+
return fullValue(event.url, 50);
|
|
841
|
+
// diagnostics render as banners and notes as chat bubbles — no
|
|
842
|
+
// pill, so no pill tooltip
|
|
843
|
+
case Events.ERROR:
|
|
844
|
+
case Events.FAILURE:
|
|
845
|
+
case Events.WARNING:
|
|
846
|
+
case Events.TICKET_NOTE_ADDED:
|
|
847
|
+
return null;
|
|
848
|
+
default:
|
|
849
|
+
return [];
|
|
567
850
|
}
|
|
568
|
-
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// a detail line in the tooltip: short values stay on one line (the
|
|
854
|
+
// tooltip hugs them), while runaway ones (a paragraph-sized field
|
|
855
|
+
// value, a long URL) wrap for a few lines within the container's cap
|
|
856
|
+
// and then ellipsize
|
|
857
|
+
const tooltipLineStyle =
|
|
858
|
+
'white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden;';
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Lays out tooltip content: the acting user's avatar on the left
|
|
862
|
+
* (when there is one) with a hairline rule, then the detail column —
|
|
863
|
+
* truncated-value lines set apart on a tinted, padded block, then a
|
|
864
|
+
* metadata line with the user's name (slightly heavier) and the
|
|
865
|
+
* detailed timestamp separated by a middle dot. When the metadata is
|
|
866
|
+
* all there is, the name and time stack on two lines instead to
|
|
867
|
+
* balance against the avatar. Without a user it's just the time.
|
|
868
|
+
*/
|
|
869
|
+
const renderTooltipContent = (
|
|
870
|
+
event: any,
|
|
871
|
+
lines: (string | TemplateResult)[]
|
|
872
|
+
): TemplateResult | null => {
|
|
873
|
+
const user = event._user;
|
|
874
|
+
const createdOn = event.created_on;
|
|
875
|
+
const iso = !createdOn
|
|
876
|
+
? null
|
|
877
|
+
: typeof createdOn === 'string'
|
|
878
|
+
? createdOn
|
|
879
|
+
: createdOn.toISOString();
|
|
880
|
+
|
|
881
|
+
if (!user && lines.length === 0 && !iso) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// the hairline rule gets equal breathing room from the flex gap and
|
|
886
|
+
// stretches to the row height. The avatar column is pinned to the
|
|
887
|
+
// circle's 26px width because temba-user carries a trailing margin
|
|
888
|
+
// (meant for an inline name) that would otherwise push the rule
|
|
889
|
+
// off-balance.
|
|
890
|
+
return html`<div
|
|
891
|
+
style="display: flex; align-items: center; gap: 8px; text-align: left; font-size: 12px; line-height: 1.45; padding: 4px 2px; max-width: 340px;"
|
|
892
|
+
>
|
|
893
|
+
${user
|
|
894
|
+
? html`<div style="flex-shrink: 0; width: 26px;">
|
|
895
|
+
${tooltipAvatar(user)}
|
|
896
|
+
</div>
|
|
897
|
+
<div
|
|
898
|
+
style="align-self: stretch; width: 1px; background: rgba(0, 0, 0, 0.08); flex-shrink: 0;"
|
|
899
|
+
></div>`
|
|
900
|
+
: null}
|
|
901
|
+
<div style="min-width: 0;">
|
|
902
|
+
${lines.length > 0
|
|
903
|
+
? html`<div
|
|
904
|
+
style="background: #f3f3f3; border-radius: 4px; padding: 4px 8px; margin-bottom: 4px;"
|
|
905
|
+
>
|
|
906
|
+
${lines.map(
|
|
907
|
+
(line) => html`<div style=${tooltipLineStyle}>${line}</div>`
|
|
908
|
+
)}
|
|
909
|
+
</div>`
|
|
910
|
+
: null}
|
|
911
|
+
${lines.length === 0 && user && iso
|
|
912
|
+
? html`<div style="font-size: 11px;">
|
|
913
|
+
<div style="font-weight: 500;">${getUserName(user)}</div>
|
|
914
|
+
<div><temba-date value=${iso} display="datetime"></temba-date></div>
|
|
915
|
+
</div>`
|
|
916
|
+
: user || iso
|
|
917
|
+
? html`<div
|
|
918
|
+
style="font-size: 11px; display: flex; align-items: center; gap: 5px;"
|
|
919
|
+
>
|
|
920
|
+
${user
|
|
921
|
+
? html`<div style="font-weight: 500;">
|
|
922
|
+
${getUserName(user)}
|
|
923
|
+
</div>`
|
|
924
|
+
: null}
|
|
925
|
+
${user && iso
|
|
926
|
+
? html`<div style="font-size: 16px; line-height: 1;">·</div>`
|
|
927
|
+
: null}
|
|
928
|
+
${iso
|
|
929
|
+
? html`<temba-date
|
|
930
|
+
value=${iso}
|
|
931
|
+
display="datetime"
|
|
932
|
+
></temba-date>`
|
|
933
|
+
: null}
|
|
934
|
+
</div>`
|
|
935
|
+
: null}
|
|
936
|
+
</div>
|
|
937
|
+
</div>`;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Builds the rich tooltip content for an event pill. Returns null for
|
|
942
|
+
* events that don't render as pills.
|
|
943
|
+
*/
|
|
944
|
+
export const renderEventTooltip = (event: any): TemplateResult | null => {
|
|
945
|
+
const lines = getEventTooltipLines(event);
|
|
946
|
+
if (lines === null) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
return renderTooltipContent(event, lines);
|
|
569
950
|
};
|
|
570
951
|
|
|
571
952
|
/**
|
|
@@ -604,7 +985,12 @@ export const renderEvent = (
|
|
|
604
985
|
content = renderUpdateEvent(event as UpdateFieldEvent);
|
|
605
986
|
break;
|
|
606
987
|
case Events.CONTACT_GROUPS_CHANGED:
|
|
607
|
-
|
|
988
|
+
// in the chat, tooltips attach per group pill (each speaks only
|
|
989
|
+
// to its own group), so the whole-line wrap below is skipped
|
|
990
|
+
content = renderContactGroupsEvent(
|
|
991
|
+
event as ContactGroupsEvent,
|
|
992
|
+
!isSimulation
|
|
993
|
+
);
|
|
608
994
|
break;
|
|
609
995
|
case Events.CONTACT_LANGUAGE_CHANGED:
|
|
610
996
|
content = renderContactLanguageChangedEvent(
|
|
@@ -626,7 +1012,7 @@ export const renderEvent = (
|
|
|
626
1012
|
content = renderInputLabelsAdded(event);
|
|
627
1013
|
break;
|
|
628
1014
|
case Events.RUN_RESULT_CHANGED:
|
|
629
|
-
content = renderRunResultChanged(event
|
|
1015
|
+
content = renderRunResultChanged(event);
|
|
630
1016
|
break;
|
|
631
1017
|
case Events.OPTIN_REQUESTED:
|
|
632
1018
|
case Events.OPTIN_STARTED:
|
|
@@ -675,11 +1061,11 @@ export const renderEvent = (
|
|
|
675
1061
|
case Events.TICKET_TOPIC_CHANGED: {
|
|
676
1062
|
// event.topic is optional — guard so a payload without one
|
|
677
1063
|
// degrades cleanly instead of throwing inside topicPill.
|
|
1064
|
+
// The topic pill (icon + topic color) is self-contained enough
|
|
1065
|
+
// to stand alone without a "Topic changed to" sentence.
|
|
678
1066
|
const newTopic = (event as TicketEvent).topic;
|
|
679
1067
|
content = newTopic
|
|
680
|
-
? html`<div style=${eventLineStyle}
|
|
681
|
-
Topic changed to ${topicPill(newTopic)}
|
|
682
|
-
</div>`
|
|
1068
|
+
? html`<div style=${eventLineStyle}>${topicPill(newTopic)}</div>`
|
|
683
1069
|
: null;
|
|
684
1070
|
break;
|
|
685
1071
|
}
|
|
@@ -693,6 +1079,30 @@ export const renderEvent = (
|
|
|
693
1079
|
|
|
694
1080
|
// wrap in a div with appropriate font size
|
|
695
1081
|
const fontSize = isSimulation ? '11px' : '14px';
|
|
1082
|
+
|
|
1083
|
+
// in the contact chat, hovering an event pill pops our own rich
|
|
1084
|
+
// tooltip (verbose detail, avatars, detailed time). The simulator
|
|
1085
|
+
// keeps its click-to-inspect event details instead, and group
|
|
1086
|
+
// events carry per-pill tips (see renderContactGroupsEvent). width:
|
|
1087
|
+
// fit-content keeps the hover area hugging the pills rather than
|
|
1088
|
+
// spanning the row.
|
|
1089
|
+
const tooltip =
|
|
1090
|
+
isSimulation || event.type === Events.CONTACT_GROUPS_CHANGED
|
|
1091
|
+
? null
|
|
1092
|
+
: renderEventTooltip(event);
|
|
1093
|
+
if (tooltip) {
|
|
1094
|
+
return html`<div style="font-size: ${fontSize}">
|
|
1095
|
+
<temba-tip
|
|
1096
|
+
style="display: block; width: fit-content; max-width: 100%; margin: 0 auto;"
|
|
1097
|
+
position="top"
|
|
1098
|
+
distance="4"
|
|
1099
|
+
arrow-size="18"
|
|
1100
|
+
interactive
|
|
1101
|
+
.content=${tooltip}
|
|
1102
|
+
>${content}</temba-tip
|
|
1103
|
+
>
|
|
1104
|
+
</div>`;
|
|
1105
|
+
}
|
|
696
1106
|
return html`<div style="font-size: ${fontSize}">${content}</div>`;
|
|
697
1107
|
};
|
|
698
1108
|
|