@nyaruka/temba-components 0.156.17 → 0.157.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 +17 -0
- package/dist/temba-components.js +1189 -767
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +14 -0
- package/src/display/Label.ts +156 -2
- package/src/display/Options.ts +71 -16
- package/src/display/TembaUser.ts +23 -5
- package/src/events/eventRenderers.ts +104 -41
- package/src/excellent/caret-utils.ts +0 -1
- package/src/flow/RevisionsWindow.ts +53 -9
- package/src/flow/nodes/shared.ts +14 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +33 -8
- package/src/flow/revision-summary.ts +25 -0
- package/src/flow/utils.ts +38 -40
- package/src/form/ArrayEditor.ts +9 -11
- package/src/form/Checkbox.ts +2 -2
- package/src/form/Compose.ts +1 -1
- package/src/form/FieldElement.ts +8 -8
- package/src/form/KeyValueEditor.ts +4 -4
- package/src/form/MessageEditor.ts +2 -3
- package/src/form/RangePicker.ts +17 -17
- package/src/form/TembaSlider.ts +10 -10
- package/src/form/TemplateEditor.ts +4 -4
- package/src/form/TextInput.ts +19 -1
- package/src/form/select/Omnibox.ts +22 -19
- package/src/form/select/Select.ts +379 -171
- package/src/form/select/WorkspaceSelect.ts +7 -1
- package/src/layout/Accordion.ts +2 -2
- package/src/layout/Modax.ts +1 -1
- package/src/list/SortableList.ts +159 -0
- package/src/live/ContactChat.ts +46 -44
- package/src/live/ContactDetails.ts +1 -0
- package/src/live/ContactFieldEditor.ts +38 -31
- package/src/live/FieldManager.ts +4 -4
- package/src/styles/designTokens.ts +145 -0
- package/src/styles/pillVariants.ts +136 -0
- package/static/css/temba-components.css +106 -28
- package/web-test-runner.config.mjs +98 -0
package/package.json
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface ObjectReference {
|
|
|
67
67
|
interface User extends ObjectReference {
|
|
68
68
|
avatar?: string;
|
|
69
69
|
email: string;
|
|
70
|
+
first_name?: string;
|
|
71
|
+
last_name?: string;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export interface Msg {
|
|
@@ -153,6 +155,11 @@ export class Chat extends RapidElement {
|
|
|
153
155
|
left: 0;
|
|
154
156
|
right: 0;
|
|
155
157
|
display: block;
|
|
158
|
+
/* The slot overlays the bottom of the chat history, so clicks
|
|
159
|
+
on the chat scrollbar or messages behind it must pass
|
|
160
|
+
through. Slotted footer content can opt back in with
|
|
161
|
+
pointer-events: auto on its interactive bits. */
|
|
162
|
+
pointer-events: none;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
.block {
|
|
@@ -447,6 +454,9 @@ export class Chat extends RapidElement {
|
|
|
447
454
|
display: none;
|
|
448
455
|
}
|
|
449
456
|
|
|
457
|
+
/* Top/bottom scroll-shadow indicators. Decorative only — they
|
|
458
|
+
must not intercept clicks (would otherwise block the chat
|
|
459
|
+
scrollbar and the bottom edge of the messages area). */
|
|
450
460
|
.messages:before {
|
|
451
461
|
content: '';
|
|
452
462
|
background: radial-gradient(
|
|
@@ -461,6 +471,7 @@ export class Chat extends RapidElement {
|
|
|
461
471
|
width: 100%;
|
|
462
472
|
transition: opacity var(--toggle-speed, 200ms) ease-out;
|
|
463
473
|
z-index: 1;
|
|
474
|
+
pointer-events: none;
|
|
464
475
|
}
|
|
465
476
|
|
|
466
477
|
.messages:after {
|
|
@@ -480,6 +491,7 @@ export class Chat extends RapidElement {
|
|
|
480
491
|
margin-right: 5em;
|
|
481
492
|
transition: opacity var(--toggle-speed, 200ms) ease-out;
|
|
482
493
|
z-index: 1;
|
|
494
|
+
pointer-events: none;
|
|
483
495
|
}
|
|
484
496
|
|
|
485
497
|
.bubble-wrap {
|
|
@@ -1262,6 +1274,8 @@ export class Chat extends RapidElement {
|
|
|
1262
1274
|
<temba-user
|
|
1263
1275
|
uuid=${currentMsg._user?.uuid}
|
|
1264
1276
|
name=${name}
|
|
1277
|
+
first_name=${currentMsg._user?.first_name}
|
|
1278
|
+
last_name=${currentMsg._user?.last_name}
|
|
1265
1279
|
avatar=${currentMsg._user?.avatar}
|
|
1266
1280
|
?system=${isSystem}
|
|
1267
1281
|
>
|
package/src/display/Label.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { LitElement, TemplateResult, html, css } from 'lit';
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
|
+
import { msg } from '@lit/localize';
|
|
3
4
|
import { getClasses } from '../utils';
|
|
4
5
|
import { styleMap } from 'lit-html/directives/style-map.js';
|
|
6
|
+
import { designTokens } from '../styles/designTokens';
|
|
7
|
+
import {
|
|
8
|
+
pillVariants,
|
|
9
|
+
PILL_TYPES,
|
|
10
|
+
PILL_TYPE_ICONS
|
|
11
|
+
} from '../styles/pillVariants';
|
|
5
12
|
|
|
6
13
|
export default class Label extends LitElement {
|
|
7
14
|
static get styles() {
|
|
8
15
|
return css`
|
|
16
|
+
${designTokens}
|
|
17
|
+
|
|
9
18
|
:host {
|
|
10
19
|
display: inline-block;
|
|
20
|
+
/* Cap at parent width so a pill sitting in a constrained
|
|
21
|
+
container (e.g. a flow canvas node body) shrinks to fit
|
|
22
|
+
rather than overflowing. The slot/mask below have the
|
|
23
|
+
min-width:0 needed to let the ellipsis engage. */
|
|
24
|
+
max-width: 100%;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
slot {
|
|
@@ -15,12 +29,19 @@ export default class Label extends LitElement {
|
|
|
15
29
|
overflow-x: hidden;
|
|
16
30
|
text-overflow: ellipsis;
|
|
17
31
|
display: block;
|
|
32
|
+
/* Without min-width:0 the slot — as a flex item inside .mask —
|
|
33
|
+
refuses to shrink below its content size, defeating the
|
|
34
|
+
overflow/ellipsis. */
|
|
35
|
+
min-width: 0;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
.mask {
|
|
21
39
|
padding: 3px 8px;
|
|
22
40
|
border-radius: 12px;
|
|
23
41
|
display: flex;
|
|
42
|
+
/* Same reason as slot — let the mask shrink below its content
|
|
43
|
+
size so the inner slot can ellipsize. */
|
|
44
|
+
min-width: 0;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
temba-icon {
|
|
@@ -43,6 +64,72 @@ export default class Label extends LitElement {
|
|
|
43
64
|
text-shadow: none;
|
|
44
65
|
}
|
|
45
66
|
|
|
67
|
+
/* DS pill mode — engaged when the consumer sets [type]. Overrides
|
|
68
|
+
the legacy chip chrome (shadow) and shape (12px radius) with
|
|
69
|
+
the design-system pill (flat, type-colored via .pill-{type},
|
|
70
|
+
1px border, 999px radius). Background/foreground/icon-color
|
|
71
|
+
are owned by pillVariants — we only set non-color chrome here
|
|
72
|
+
so we don't outrank the variant. */
|
|
73
|
+
.label[class*='pill-'] {
|
|
74
|
+
font-size: 11.5px;
|
|
75
|
+
font-weight: var(--w-regular);
|
|
76
|
+
/* border color is owned by .pill-{type} in pillVariants — only
|
|
77
|
+
set width/style here so we don't outrank the variant. */
|
|
78
|
+
border-width: 1px;
|
|
79
|
+
border-style: solid;
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
box-shadow: none;
|
|
82
|
+
}
|
|
83
|
+
.label[class*='pill-'] .mask {
|
|
84
|
+
padding: 0 7px;
|
|
85
|
+
height: 20px;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 4px;
|
|
88
|
+
border-radius: 999px;
|
|
89
|
+
}
|
|
90
|
+
/* Hover tint pulled from the pill's own foreground (which is the
|
|
91
|
+
dark variant shade), so flow stays bluish, group stays purplish,
|
|
92
|
+
etc. — no grey wash. */
|
|
93
|
+
.label[class*='pill-'].clickable .mask:hover {
|
|
94
|
+
background: color-mix(in oklab, currentColor 10%, transparent);
|
|
95
|
+
}
|
|
96
|
+
.label[class*='pill-'] temba-icon {
|
|
97
|
+
margin-right: 0;
|
|
98
|
+
padding-bottom: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Chip-style X button — matches the multi-select chip's
|
|
102
|
+
.remove-item. currentColor-tinted bg so it picks up the
|
|
103
|
+
pill variant's hue. Sits on the left, ahead of the icon. */
|
|
104
|
+
.label[class*='pill-'] .remove {
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
width: 16px;
|
|
110
|
+
height: 16px;
|
|
111
|
+
padding: 0;
|
|
112
|
+
margin: 0;
|
|
113
|
+
border: 0;
|
|
114
|
+
border-radius: 999px;
|
|
115
|
+
background: color-mix(in oklab, currentColor 25%, transparent);
|
|
116
|
+
color: inherit;
|
|
117
|
+
opacity: 0.8;
|
|
118
|
+
--icon-color: currentColor;
|
|
119
|
+
}
|
|
120
|
+
.label[class*='pill-'] .remove:hover {
|
|
121
|
+
opacity: 1;
|
|
122
|
+
background: color-mix(in oklab, currentColor 45%, transparent);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* When a removable X is present, tighten the mask's left padding
|
|
126
|
+
so the X sits snug against the pill edge (matches the
|
|
127
|
+
multi-select chip's 4px left padding). Right padding stays so
|
|
128
|
+
the trailing icon/name keep their breathing room. */
|
|
129
|
+
.label[class*='pill-']:has(.remove) .mask {
|
|
130
|
+
padding-left: 4px;
|
|
131
|
+
}
|
|
132
|
+
|
|
46
133
|
.danger {
|
|
47
134
|
background: tomato;
|
|
48
135
|
color: #fff;
|
|
@@ -79,6 +166,11 @@ export default class Label extends LitElement {
|
|
|
79
166
|
.shadow {
|
|
80
167
|
box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
|
|
81
168
|
}
|
|
169
|
+
|
|
170
|
+
/* DS pill variants come last so .pill-{type} wins on source
|
|
171
|
+
order against equal-specificity legacy rules above (.label,
|
|
172
|
+
.danger, .primary, etc.). */
|
|
173
|
+
${pillVariants}
|
|
82
174
|
`;
|
|
83
175
|
}
|
|
84
176
|
|
|
@@ -106,12 +198,47 @@ export default class Label extends LitElement {
|
|
|
106
198
|
@property({ type: String })
|
|
107
199
|
icon: string;
|
|
108
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Design-system pill variant — `flow`, `group`, `contact`, `field`,
|
|
203
|
+
* `keyword`, `label`, or `neutral`. When set, switches the chrome to
|
|
204
|
+
* a flat DS pill (rounded 999px, type-colored). Stays the legacy
|
|
205
|
+
* shadowed label when unset, so existing consumers are unaffected.
|
|
206
|
+
*/
|
|
207
|
+
@property({ type: String })
|
|
208
|
+
type: string;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Render a chip-style X button on the left of the pill. Clicking it
|
|
212
|
+
* fires a `temba-remove` event; the rest of the pill stays clickable
|
|
213
|
+
* for navigation as usual.
|
|
214
|
+
*/
|
|
215
|
+
@property({ type: Boolean })
|
|
216
|
+
removable: boolean;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Accessible label for the remove button. Defaults to a localized
|
|
220
|
+
* "Remove", but consumers whose action verb differs (e.g.
|
|
221
|
+
* "Interrupt flow") should pass their own — the X button is the
|
|
222
|
+
* affordance for whatever action `temba-remove` triggers, so the
|
|
223
|
+
* accessible name should match.
|
|
224
|
+
*/
|
|
225
|
+
@property({ type: String })
|
|
226
|
+
removeLabel: string;
|
|
227
|
+
|
|
109
228
|
@property()
|
|
110
229
|
backgroundColor: string;
|
|
111
230
|
|
|
112
231
|
@property()
|
|
113
232
|
textColor: string;
|
|
114
233
|
|
|
234
|
+
private handleRemove(e: MouseEvent) {
|
|
235
|
+
e.stopPropagation();
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
this.dispatchEvent(
|
|
238
|
+
new CustomEvent('temba-remove', { bubbles: true, composed: true })
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
115
242
|
public render(): TemplateResult {
|
|
116
243
|
const labelStyle = {};
|
|
117
244
|
|
|
@@ -124,6 +251,22 @@ export default class Label extends LitElement {
|
|
|
124
251
|
labelStyle['--icon-color'] = this.textColor;
|
|
125
252
|
}
|
|
126
253
|
|
|
254
|
+
// Only emit `pill-${this.type}` if it's a recognized variant.
|
|
255
|
+
// An unknown value (or one containing whitespace, e.g.
|
|
256
|
+
// `"flow danger"` from a malformed template) would otherwise split
|
|
257
|
+
// into multiple classes and collide with internal modifiers
|
|
258
|
+
// (`.danger`, `.shadow`, `.clickable`, etc.) defined on `.label`.
|
|
259
|
+
const validType = this.type && PILL_TYPES.has(this.type);
|
|
260
|
+
const variantClass = validType ? `pill-${this.type}` : '';
|
|
261
|
+
// When the consumer sets a recognized `type` (group / flow / etc.)
|
|
262
|
+
// but doesn't supply an explicit `icon`, fall back to the type's
|
|
263
|
+
// default icon from PILL_TYPE_ICONS. Call sites then only need
|
|
264
|
+
// `type="group"` instead of `type="group" icon="group"`.
|
|
265
|
+
const resolvedIcon =
|
|
266
|
+
this.icon || (validType ? PILL_TYPE_ICONS[this.type] : undefined);
|
|
267
|
+
|
|
268
|
+
const removeAriaLabel = this.removeLabel || msg('Remove');
|
|
269
|
+
|
|
127
270
|
return html`
|
|
128
271
|
<div
|
|
129
272
|
class="label ${getClasses({
|
|
@@ -134,11 +277,22 @@ export default class Label extends LitElement {
|
|
|
134
277
|
shadow: this.shadow,
|
|
135
278
|
danger: this.danger,
|
|
136
279
|
dark: this.dark
|
|
137
|
-
})}"
|
|
280
|
+
})} ${variantClass}"
|
|
138
281
|
style=${styleMap(labelStyle)}
|
|
139
282
|
>
|
|
140
283
|
<div class="mask">
|
|
141
|
-
${this.
|
|
284
|
+
${this.removable
|
|
285
|
+
? html`<button
|
|
286
|
+
class="remove"
|
|
287
|
+
@click=${this.handleRemove}
|
|
288
|
+
aria-label=${removeAriaLabel}
|
|
289
|
+
>
|
|
290
|
+
<temba-icon name="x" size="0.85"></temba-icon>
|
|
291
|
+
</button>`
|
|
292
|
+
: null}
|
|
293
|
+
${resolvedIcon
|
|
294
|
+
? html`<temba-icon name=${resolvedIcon} />`
|
|
295
|
+
: null}
|
|
142
296
|
<slot></slot>
|
|
143
297
|
</div>
|
|
144
298
|
</div>
|
package/src/display/Options.ts
CHANGED
|
@@ -5,10 +5,13 @@ import { RapidElement, EventHandler } from '../RapidElement';
|
|
|
5
5
|
import { styleMap } from 'lit-html/directives/style-map.js';
|
|
6
6
|
import { getClasses, getScrollParent, throttle } from '../utils';
|
|
7
7
|
import { msg } from '@lit/localize';
|
|
8
|
+
import { designTokens } from '../styles/designTokens';
|
|
8
9
|
|
|
9
10
|
export class Options extends RapidElement {
|
|
10
11
|
static get styles() {
|
|
11
12
|
return css`
|
|
13
|
+
${designTokens}
|
|
14
|
+
|
|
12
15
|
:host {
|
|
13
16
|
--transition-speed: 0;
|
|
14
17
|
}
|
|
@@ -41,6 +44,10 @@ export class Options extends RapidElement {
|
|
|
41
44
|
flex-grow: 1;
|
|
42
45
|
height: 100%;
|
|
43
46
|
border: none;
|
|
47
|
+
/* Block-mode is inline (ticket sidebar, etc.) — drop the
|
|
48
|
+
z-index so floating popups (selects, dropdowns) opened over
|
|
49
|
+
the block list always stack above its options. */
|
|
50
|
+
z-index: auto;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
:host([block]) .options-scroll {
|
|
@@ -96,25 +103,75 @@ export class Options extends RapidElement {
|
|
|
96
103
|
border: none;
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
/* When shown, the popup is opaque and clickable. Keep the
|
|
107
|
+
high z-index from .options-container so the popup stacks
|
|
108
|
+
above neighboring widgets — e.g. embedded prefix labels of
|
|
109
|
+
later form fields, which sit absolutely positioned at the
|
|
110
|
+
top edge of their host element. */
|
|
99
111
|
.show {
|
|
100
|
-
border: 1px solid var(--color-widget-border);
|
|
101
112
|
opacity: 1;
|
|
102
|
-
z-index: 1;
|
|
103
113
|
pointer-events: auto;
|
|
104
114
|
margin-top: var(--options-margin-top);
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
/* Floating popup border uses --focus-muted (not --color-focus)
|
|
118
|
+
so a parent field's error state — which overrides
|
|
119
|
+
--color-focus to red via .has-error — doesn't turn the
|
|
120
|
+
popup red. The popup stays blue regardless. Single source
|
|
121
|
+
of truth: --focus in designTokens. No halo here — the
|
|
122
|
+
dropdown is an attached panel, not its own focus indicator
|
|
123
|
+
(the parent select keeps its own halo). Block-mode renders
|
|
124
|
+
inline and skips this rule entirely. */
|
|
125
|
+
:host(:not([block])) .show {
|
|
126
|
+
border: 1px solid var(--temba-options-focus-border, var(--focus-muted));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Each option is a DS-style list row: flat, fixed height,
|
|
130
|
+
tight padding, no inter-item margin. Full-bleed background on
|
|
131
|
+
hover/focus → no border-radius (rounded corners would leave
|
|
132
|
+
visible gaps at the dropdown edges). */
|
|
107
133
|
.option {
|
|
108
|
-
font-size: var(--temba-options-font-size);
|
|
109
|
-
padding: var(--temba-options-option-padding,
|
|
110
|
-
border-radius: var(--temba-options-option-radius,
|
|
111
|
-
margin: var(--temba-options-option-margin, 0
|
|
134
|
+
font-size: var(--temba-options-font-size, 13.5px);
|
|
135
|
+
padding: var(--temba-options-option-padding, 0 var(--pad));
|
|
136
|
+
border-radius: var(--temba-options-option-radius, 0);
|
|
137
|
+
margin: var(--temba-options-option-margin, 0);
|
|
138
|
+
min-height: var(--temba-options-option-min-height, 32px);
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
112
141
|
cursor: pointer;
|
|
113
|
-
color: var(--
|
|
142
|
+
color: var(--text-1);
|
|
114
143
|
scroll-margin: 5px 0px;
|
|
115
144
|
text-align: left;
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
/* A single wrapping renderOption child stretches to fill the row
|
|
148
|
+
so custom templates (e.g. ones using justify-content:
|
|
149
|
+
space-between to right-align trailing badges) get the full
|
|
150
|
+
width to lay out in. Scoped to :only-child so a renderOption
|
|
151
|
+
that emits multiple top-level siblings keeps its natural
|
|
152
|
+
layout instead of getting an equal-width flex partition. */
|
|
153
|
+
.option > :only-child {
|
|
154
|
+
flex: 1;
|
|
155
|
+
min-width: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Block-mode (inline, always-visible lists like the ticket
|
|
159
|
+
sidebar): inset every option uniformly from the container
|
|
160
|
+
and from each other (padding + flex gap), and re-add the
|
|
161
|
+
radius so the focused/active wash reads as a rounded pill
|
|
162
|
+
instead of a stripe. Rich custom renderOption content
|
|
163
|
+
(multi-line ticket cards, etc.) also wants more vertical
|
|
164
|
+
padding than the dropdown's compact rows. */
|
|
165
|
+
:host([block]) .options-scroll {
|
|
166
|
+
padding: 4px;
|
|
167
|
+
gap: 4px;
|
|
168
|
+
}
|
|
169
|
+
:host([block]) .option {
|
|
170
|
+
margin: 0;
|
|
171
|
+
padding: 8px var(--pad);
|
|
172
|
+
border-radius: var(--r-sm);
|
|
173
|
+
}
|
|
174
|
+
|
|
118
175
|
.option * {
|
|
119
176
|
user-select: none;
|
|
120
177
|
-webkit-user-select: none;
|
|
@@ -169,20 +226,18 @@ export class Options extends RapidElement {
|
|
|
169
226
|
max-height: 1.1em;
|
|
170
227
|
}
|
|
171
228
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
color: var(--temba-options-option-hover-text, var(--option-hover-text));
|
|
178
|
-
}
|
|
179
|
-
|
|
229
|
+
/* The component syncs cursorIndex to pointer position on
|
|
230
|
+
mousemove, so .focused already follows the mouse. A
|
|
231
|
+
separate :hover rule would just create a second highlight
|
|
232
|
+
that flickers between rows on transition — keep only the
|
|
233
|
+
single focused/active state. */
|
|
180
234
|
.option.focused {
|
|
181
235
|
background: var(
|
|
182
236
|
--temba-options-option-focus-bg,
|
|
183
237
|
var(--color-selection)
|
|
184
238
|
);
|
|
185
|
-
color: var(--temba-options-option-focus-text, var(--
|
|
239
|
+
color: var(--temba-options-option-focus-text, var(--accent-700));
|
|
240
|
+
--icon-color: var(--accent-700);
|
|
186
241
|
}
|
|
187
242
|
|
|
188
243
|
.option.no-options {
|
package/src/display/TembaUser.ts
CHANGED
|
@@ -11,7 +11,10 @@ export const getFullName = (user: {
|
|
|
11
11
|
first_name?: string;
|
|
12
12
|
last_name?: string;
|
|
13
13
|
}) => {
|
|
14
|
-
|
|
14
|
+
if (user.first_name || user.last_name) {
|
|
15
|
+
return [user.first_name, user.last_name].filter(Boolean).join(' ');
|
|
16
|
+
}
|
|
17
|
+
return user.name || '';
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export class TembaUser extends RapidElement {
|
|
@@ -59,6 +62,12 @@ export class TembaUser extends RapidElement {
|
|
|
59
62
|
@property({ type: String })
|
|
60
63
|
name: string;
|
|
61
64
|
|
|
65
|
+
@property({ type: String })
|
|
66
|
+
first_name: string;
|
|
67
|
+
|
|
68
|
+
@property({ type: String })
|
|
69
|
+
last_name: string;
|
|
70
|
+
|
|
62
71
|
@property({ type: String })
|
|
63
72
|
email: string;
|
|
64
73
|
|
|
@@ -75,10 +84,19 @@ export class TembaUser extends RapidElement {
|
|
|
75
84
|
this.bgimage = `url('${DEFAULT_AVATAR}') center / contain no-repeat`;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
if (
|
|
88
|
+
changed.has('name') ||
|
|
89
|
+
changed.has('first_name') ||
|
|
90
|
+
changed.has('last_name')
|
|
91
|
+
) {
|
|
92
|
+
const fullName = getFullName({
|
|
93
|
+
name: this.name,
|
|
94
|
+
first_name: this.first_name,
|
|
95
|
+
last_name: this.last_name
|
|
96
|
+
});
|
|
97
|
+
if (fullName) {
|
|
98
|
+
this.bgcolor = colorHash.hex(fullName);
|
|
99
|
+
this.initials = extractInitials(fullName);
|
|
82
100
|
} else {
|
|
83
101
|
this.bgcolor = '#e6e6e6';
|
|
84
102
|
this.initials = '';
|
|
@@ -56,28 +56,100 @@ export enum Events {
|
|
|
56
56
|
WEBHOOK_CALLED = 'webhook_called'
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Renders a single DS pill of the given type. temba-label auto-resolves
|
|
61
|
+
* the icon from `type` (via PILL_TYPE_ICONS), so we don't pass `icon`
|
|
62
|
+
* unless the consumer explicitly overrides it. When `href` is provided
|
|
63
|
+
* the pill is wrapped in a navigation anchor (SPA goto); otherwise it's
|
|
64
|
+
* rendered inline as a plain pill. Single source of truth for the
|
|
65
|
+
* "entity pill in chat history" look — inline margin keeps wrapping
|
|
66
|
+
* airy, and inline style works regardless of host-page Tailwind reach.
|
|
67
|
+
*/
|
|
68
|
+
const renderEntityPill = (
|
|
69
|
+
pillType: string,
|
|
70
|
+
name: string,
|
|
71
|
+
opts: { href?: string; icon?: string } = {}
|
|
72
|
+
): TemplateResult => {
|
|
73
|
+
const pill = opts.icon
|
|
74
|
+
? html`<temba-label
|
|
75
|
+
icon=${opts.icon}
|
|
76
|
+
type=${pillType}
|
|
77
|
+
?clickable=${!!opts.href}
|
|
78
|
+
style="margin: 1px 2px; vertical-align: middle;"
|
|
79
|
+
>${name}</temba-label
|
|
80
|
+
>`
|
|
81
|
+
: html`<temba-label
|
|
82
|
+
type=${pillType}
|
|
83
|
+
?clickable=${!!opts.href}
|
|
84
|
+
style="margin: 1px 2px; vertical-align: middle;"
|
|
85
|
+
>${name}</temba-label
|
|
86
|
+
>`;
|
|
87
|
+
return opts.href
|
|
88
|
+
? html`<a
|
|
89
|
+
href=${opts.href}
|
|
90
|
+
onclick="goto(event, this)"
|
|
91
|
+
style="vertical-align: middle;"
|
|
92
|
+
>${pill}</a
|
|
93
|
+
>`
|
|
94
|
+
: pill;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const groupPill = (item: any) =>
|
|
98
|
+
renderEntityPill('group', item.name, {
|
|
99
|
+
href: `/contact/group/${item.uuid}/`
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const flowPill = (flow: any) =>
|
|
103
|
+
renderEntityPill('flow', flow.name, {
|
|
104
|
+
href: `/flow/editor/${flow.uuid}/`
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const fieldPill = (field: any) => renderEntityPill('field', field.name);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Renders a generic value as a neutral pill (white bg, gray border).
|
|
111
|
+
* Used for "after" values in update/change events — visually paired
|
|
112
|
+
* with the type pill on the left side of the line, without claiming
|
|
113
|
+
* a domain hue.
|
|
114
|
+
*/
|
|
115
|
+
const valuePill = (value: string | number) =>
|
|
116
|
+
html`<span
|
|
117
|
+
style="display: inline-flex; align-items: center; height: 20px; padding: 0 8px; margin: 1px 2px; border-radius: 999px; border: 1px solid var(--border-strong, #d2d6dc); background: #fff; color: var(--text-1, #1a1f26); font-size: 11.5px; font-weight: 400; line-height: 1; vertical-align: middle;"
|
|
118
|
+
>${value}</span
|
|
119
|
+
>`;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Inline-flex wrapper style that text + pills share. Without it,
|
|
123
|
+
* plain text sits on its own baseline while vertical-align: middle
|
|
124
|
+
* pills sit slightly above and the text appears to "float". flex
|
|
125
|
+
* centering keeps the row of words and pills on one cross-axis.
|
|
126
|
+
*/
|
|
127
|
+
const eventLineStyle =
|
|
128
|
+
'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px;';
|
|
129
|
+
|
|
59
130
|
const renderInfoList = (
|
|
60
131
|
singular: string,
|
|
61
132
|
plural: string,
|
|
62
133
|
items: any[]
|
|
63
134
|
): TemplateResult => {
|
|
64
135
|
if (items.length === 1) {
|
|
65
|
-
return html`<div
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (list.length === 2) {
|
|
69
|
-
return html`<div>
|
|
70
|
-
${plural} <strong>${list[0]}</strong> and <strong>${list[1]}</strong>
|
|
71
|
-
</div>`;
|
|
72
|
-
} else {
|
|
73
|
-
const last = list.pop();
|
|
74
|
-
const middle = list.map(
|
|
75
|
-
(name, index) =>
|
|
76
|
-
html`<strong>${name}</strong>${index < list.length - 1 ? ', ' : ''}`
|
|
77
|
-
);
|
|
78
|
-
return html`<div>${plural} ${middle}, and <strong>${last}</strong></div>`;
|
|
79
|
-
}
|
|
136
|
+
return html`<div style=${eventLineStyle}>
|
|
137
|
+
${singular} ${groupPill(items[0])}
|
|
138
|
+
</div>`;
|
|
80
139
|
}
|
|
140
|
+
if (items.length === 2) {
|
|
141
|
+
return html`<div style=${eventLineStyle}>
|
|
142
|
+
${plural} ${groupPill(items[0])} and ${groupPill(items[1])}
|
|
143
|
+
</div>`;
|
|
144
|
+
}
|
|
145
|
+
// No commas between pills — the flex `gap` on eventLineStyle
|
|
146
|
+
// already provides visual separation, and a pill list reads as a
|
|
147
|
+
// single "set" rather than a sentence.
|
|
148
|
+
const middle = items.slice(0, -1).map((item) => groupPill(item));
|
|
149
|
+
const last = items[items.length - 1];
|
|
150
|
+
return html`<div style=${eventLineStyle}>
|
|
151
|
+
${plural} ${middle} and ${groupPill(last)}
|
|
152
|
+
</div>`;
|
|
81
153
|
};
|
|
82
154
|
|
|
83
155
|
export const renderRunEvent = (event: RunEvent): TemplateResult => {
|
|
@@ -92,11 +164,8 @@ export const renderRunEvent = (event: RunEvent): TemplateResult => {
|
|
|
92
164
|
}
|
|
93
165
|
}
|
|
94
166
|
|
|
95
|
-
return html`<div>
|
|
96
|
-
${verb}
|
|
97
|
-
<a href="/flow/editor/${event.flow.uuid}/"
|
|
98
|
-
><strong>${event.flow.name}</strong></a
|
|
99
|
-
>
|
|
167
|
+
return html`<div style=${eventLineStyle}>
|
|
168
|
+
${verb} ${flowPill(event.flow)}
|
|
100
169
|
</div>`;
|
|
101
170
|
};
|
|
102
171
|
|
|
@@ -112,27 +181,27 @@ export const renderChatStartedEvent = (
|
|
|
112
181
|
|
|
113
182
|
export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
|
|
114
183
|
return event.value
|
|
115
|
-
? html`<div>
|
|
116
|
-
Updated
|
|
117
|
-
<strong>${event.value.text}</strong>
|
|
184
|
+
? html`<div style=${eventLineStyle}>
|
|
185
|
+
Updated ${fieldPill(event.field)} to ${valuePill(event.value.text)}
|
|
118
186
|
</div>`
|
|
119
|
-
: html`<div
|
|
187
|
+
: html`<div style=${eventLineStyle}>
|
|
188
|
+
Cleared ${fieldPill(event.field)}
|
|
189
|
+
</div>`;
|
|
120
190
|
};
|
|
121
191
|
|
|
122
192
|
export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
|
|
123
|
-
return html`<div>
|
|
124
|
-
Updated
|
|
193
|
+
return html`<div style=${eventLineStyle}>
|
|
194
|
+
Updated name to ${valuePill(event.name)}
|
|
125
195
|
</div>`;
|
|
126
196
|
};
|
|
127
197
|
|
|
128
198
|
export const renderContactURNsChanged = (
|
|
129
199
|
event: URNsChangedEvent
|
|
130
200
|
): TemplateResult => {
|
|
131
|
-
return html`<div>
|
|
132
|
-
Updated
|
|
133
|
-
${oxfordFn(
|
|
134
|
-
|
|
135
|
-
(urn: string) => html`<strong>${urn.split(':')[1].split('?')[0]}</strong>`
|
|
201
|
+
return html`<div style=${eventLineStyle}>
|
|
202
|
+
Updated URNs to
|
|
203
|
+
${oxfordFn(event.urns, (urn: string) =>
|
|
204
|
+
valuePill(urn.split(':')[1].split('?')[0])
|
|
136
205
|
)}
|
|
137
206
|
</div>`;
|
|
138
207
|
};
|
|
@@ -214,15 +283,11 @@ export const renderContactGroupsEvent = (
|
|
|
214
283
|
): TemplateResult => {
|
|
215
284
|
const groupsEvent = event as ContactGroupsEvent;
|
|
216
285
|
if (groupsEvent.groups_added) {
|
|
217
|
-
return renderInfoList(
|
|
218
|
-
'Added to group',
|
|
219
|
-
'Added to groups',
|
|
220
|
-
groupsEvent.groups_added
|
|
221
|
-
);
|
|
286
|
+
return renderInfoList('Added to', 'Added to', groupsEvent.groups_added);
|
|
222
287
|
} else if (groupsEvent.groups_removed) {
|
|
223
288
|
return renderInfoList(
|
|
224
|
-
'Removed from
|
|
225
|
-
'Removed from
|
|
289
|
+
'Removed from',
|
|
290
|
+
'Removed from',
|
|
226
291
|
groupsEvent.groups_removed
|
|
227
292
|
);
|
|
228
293
|
}
|
|
@@ -375,9 +440,7 @@ export const renderBroadcastCreated = (event: any): TemplateResult | null => {
|
|
|
375
440
|
export const renderSessionTriggered = (event: any): TemplateResult | null => {
|
|
376
441
|
const flow = event.flow;
|
|
377
442
|
if (flow) {
|
|
378
|
-
return html`<div>
|
|
379
|
-
Started somebody else in <strong>${flow.name}</strong>
|
|
380
|
-
</div>`;
|
|
443
|
+
return html`<div>Started somebody else in ${flowPill(flow)}</div>`;
|
|
381
444
|
}
|
|
382
445
|
return null;
|
|
383
446
|
};
|