@nyaruka/temba-components 0.162.0 → 0.164.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/temba-components.js +1865 -686
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +29 -9
- package/src/display/Lightbox.ts +57 -109
- package/src/display/Thumbnail.ts +34 -7
- package/src/flow/FlowSearch.ts +66 -2
- package/src/flow/NodeEditor.ts +109 -1
- package/src/interfaces.ts +79 -2
- package/src/layout/Card.ts +311 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +84 -24
- package/src/layout/HeaderBar.ts +38 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +45 -6
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1094 -0
- package/src/live/ContactDetails.ts +3 -4
- package/src/live/ContactFields.ts +1 -1
- package/src/live/ContactNameFetch.ts +5 -2
- package/src/live/ContactNotepad.ts +88 -2
- package/src/live/ContactStoreElement.ts +12 -2
- package/src/live/ContactTimeline.ts +5 -1
- package/src/utils.ts +19 -0
- package/temba-modules.ts +14 -0
- package/web-dev-server.config.mjs +54 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
|
+
import { state } from 'lit/decorators.js';
|
|
3
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
4
|
+
import { ContentList, ContentListColumn } from './ContentList';
|
|
5
|
+
import { Icon } from '../Icons';
|
|
6
|
+
import { CustomEventType, Trigger } from '../interfaces';
|
|
7
|
+
|
|
8
|
+
/** Placeholder shown in any cell whose value is empty. */
|
|
9
|
+
const EMPTY = '--';
|
|
10
|
+
|
|
11
|
+
/** Most pills a cell renders before folding the remainder into a
|
|
12
|
+
* "+N" summary — past this, shrinking pills to fit degrades them to
|
|
13
|
+
* bare icons that identify nothing. This is the upper bound; a cell
|
|
14
|
+
* that can't fit even this many folds further after measurement
|
|
15
|
+
* (see {@link TriggerList.measurePillFit}). */
|
|
16
|
+
const MAX_PILLS = 3;
|
|
17
|
+
|
|
18
|
+
/** Name length past which a filter pill is marked `wide` — the pill
|
|
19
|
+
* that gives up space (down to a floor) when the row is squeezed,
|
|
20
|
+
* so its short-named siblings keep their full text. */
|
|
21
|
+
const WIDE_PILL_CHARS = 11;
|
|
22
|
+
|
|
23
|
+
/** Keyword length past which a keyword pill is marked `wide`. Lower
|
|
24
|
+
* than the filter-pill threshold to match the keyword pills' compact
|
|
25
|
+
* monospace sizing and their smaller shrink floor. */
|
|
26
|
+
const WIDE_KEYWORD_CHARS = 5;
|
|
27
|
+
|
|
28
|
+
/** Quiet period after the last resize before pill budgets recompute.
|
|
29
|
+
* Resize events arrive in bursts while dragging — refolding on each
|
|
30
|
+
* one flashes the pills, so wait for the size to settle. */
|
|
31
|
+
const PILL_SYNC_DEBOUNCE = 200;
|
|
32
|
+
|
|
33
|
+
/** A resolved filter pill (channel / contact / group / excluded
|
|
34
|
+
* group) ready to render — see {@link TriggerList.renderFilters}. */
|
|
35
|
+
interface FilterPill {
|
|
36
|
+
type: string;
|
|
37
|
+
name: string;
|
|
38
|
+
href: string;
|
|
39
|
+
icon?: string;
|
|
40
|
+
exclude?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Human name for each trigger type slug — mirrors the type names in
|
|
44
|
+
* rapidpro's `triggers/types.py`. Rendered in the trigger cell only
|
|
45
|
+
* for types whose leading icon doesn't say it all (no per-type
|
|
46
|
+
* details of their own). */
|
|
47
|
+
const TYPE_NAMES: { [slug: string]: string } = {
|
|
48
|
+
keyword: 'Keyword',
|
|
49
|
+
catch_all: 'Catch All',
|
|
50
|
+
schedule: 'Schedule',
|
|
51
|
+
inbound_call: 'Inbound Call',
|
|
52
|
+
missed_call: 'Missed Call',
|
|
53
|
+
new_conversation: 'New Conversation',
|
|
54
|
+
referral: 'Referral',
|
|
55
|
+
closed_ticket: 'Closed Ticket',
|
|
56
|
+
opt_in: 'Opt-In',
|
|
57
|
+
opt_out: 'Opt-Out'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Trigger CRUDL list — drop-in replacement for the rapidpro
|
|
62
|
+
* `triggers/trigger_list.html` table. Columns: the trigger itself
|
|
63
|
+
* (a leading type icon plus the per-type details — keyword pills,
|
|
64
|
+
* schedule, referrer — or the type name when there are none), the
|
|
65
|
+
* filters that scope it (channel first, then contact / group pills,
|
|
66
|
+
* excluded groups tinted red), the flow it starts, and created on.
|
|
67
|
+
* Triggers open in an update modal rather than a page of their own,
|
|
68
|
+
* so rows carry no href — the host listens for `temba-row-click`
|
|
69
|
+
* and opens the modal itself.
|
|
70
|
+
*/
|
|
71
|
+
export class TriggerList extends ContentList<Trigger> {
|
|
72
|
+
static get styles() {
|
|
73
|
+
return css`
|
|
74
|
+
${ContentList.styles}
|
|
75
|
+
/* Rows are clickable (the host opens the update modal on
|
|
76
|
+
temba-row-click) but carry no href, so ContentList doesn't
|
|
77
|
+
mark them .clickable — carry the affordance ourselves. */
|
|
78
|
+
tr.row {
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
.type-name {
|
|
82
|
+
color: inherit;
|
|
83
|
+
font-weight: var(--w-medium);
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
}
|
|
86
|
+
/* Trigger cell — muted lead-in text ("starts with", "from")
|
|
87
|
+
ahead of the per-type specifics, with pills sitting inline. */
|
|
88
|
+
.details {
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 4px;
|
|
92
|
+
min-width: 0;
|
|
93
|
+
max-width: 100%;
|
|
94
|
+
}
|
|
95
|
+
/* The lead word never shrinks; the detail text after it ("each
|
|
96
|
+
week on Monday…", the referrer) ellipsizes when the cell runs
|
|
97
|
+
out of room — flex items ignore the cell wrapper's
|
|
98
|
+
text-overflow, so they carry their own. */
|
|
99
|
+
.details .type-name {
|
|
100
|
+
flex: 0 0 auto;
|
|
101
|
+
}
|
|
102
|
+
.details .lead-in,
|
|
103
|
+
.details .detail-text {
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
min-width: 0;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
text-overflow: ellipsis;
|
|
108
|
+
}
|
|
109
|
+
.details .lead-in {
|
|
110
|
+
color: var(--text-3);
|
|
111
|
+
}
|
|
112
|
+
/* A pill row that outgrows its cell shrinks its pills rather
|
|
113
|
+
than clipping at the cell edge: max-width pins the inline-flex
|
|
114
|
+
container to the cell (it would otherwise size to its content
|
|
115
|
+
and hard-clip under .cell-inner's overflow). Which pills give
|
|
116
|
+
up the space is deliberate — see .wide below. */
|
|
117
|
+
.pills {
|
|
118
|
+
display: inline-flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 4px;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
max-width: 100%;
|
|
123
|
+
/* Anything past the box (only possible in the frame or two
|
|
124
|
+
before measurePillFit folds the budget down) clips inside
|
|
125
|
+
the pill row rather than painting into the next column. */
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
}
|
|
128
|
+
/* Short-named pills never squeeze — with the +N cap bounding
|
|
129
|
+
the row, they always fit, so they keep their full text. */
|
|
130
|
+
.pills temba-label {
|
|
131
|
+
flex-shrink: 0;
|
|
132
|
+
}
|
|
133
|
+
/* A long-named pill is the one that gives up space, down to a
|
|
134
|
+
floor that keeps its name identifiable — temba-label's
|
|
135
|
+
internal slot ellipsis handles the truncation. */
|
|
136
|
+
.pills temba-label.wide {
|
|
137
|
+
flex-shrink: 1;
|
|
138
|
+
min-width: 7em;
|
|
139
|
+
}
|
|
140
|
+
/* Keyword pills follow the same rule — short ones never shrink
|
|
141
|
+
(so a pill can never collapse to bare chrome), long ones give
|
|
142
|
+
up space — but with a smaller floor matched to their compact
|
|
143
|
+
monospace sizing, so the floor never stretches a pill beyond
|
|
144
|
+
its natural width. */
|
|
145
|
+
.details .pills temba-label.wide {
|
|
146
|
+
min-width: 4em;
|
|
147
|
+
}
|
|
148
|
+
/* Exclusion pills — group pills derive their palette from
|
|
149
|
+
--recipient (see pillVariants.ts), so re-anchoring it to the
|
|
150
|
+
danger hue retints an excluded group red while keeping the
|
|
151
|
+
pill chrome identical to its included siblings. */
|
|
152
|
+
.pills temba-label.exclude {
|
|
153
|
+
--recipient: var(--danger);
|
|
154
|
+
}
|
|
155
|
+
/* The "+N" summary pill never shrinks — it's the affordance
|
|
156
|
+
that says more exist, so it must stay legible while its
|
|
157
|
+
siblings ellipsize. */
|
|
158
|
+
.pills temba-label.overflow {
|
|
159
|
+
flex: 0 0 auto;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
constructor() {
|
|
165
|
+
super();
|
|
166
|
+
this.valueKey = 'id';
|
|
167
|
+
this.emptyMessage = 'No triggers';
|
|
168
|
+
this.searchPlaceholder = 'Search triggers';
|
|
169
|
+
this.columns = [
|
|
170
|
+
{ key: 'trigger', label: 'Trigger', grow: true, minWidth: '260px' },
|
|
171
|
+
{
|
|
172
|
+
key: 'filters',
|
|
173
|
+
label: 'Filters',
|
|
174
|
+
minWidth: '120px',
|
|
175
|
+
maxWidth: '360px'
|
|
176
|
+
},
|
|
177
|
+
{ key: 'flow', label: 'Flow', minWidth: '110px', maxWidth: '240px' },
|
|
178
|
+
{
|
|
179
|
+
key: 'created_on',
|
|
180
|
+
label: 'Created on',
|
|
181
|
+
sortable: true,
|
|
182
|
+
minWidth: '96px',
|
|
183
|
+
maxWidth: '150px',
|
|
184
|
+
align: 'right'
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
this.bulkActions = [
|
|
188
|
+
{ key: 'archive', label: 'Archive', icon: Icon.archive }
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Per-cell visible-pill budget, keyed `${rowId}:${cellKey}`. A
|
|
193
|
+
* cell starts at {@link MAX_PILLS}; after each render the cell is
|
|
194
|
+
* measured and its budget walked down until the pills (plus the
|
|
195
|
+
* "+N" summary) actually fit — so a narrow column folds pills into
|
|
196
|
+
* the summary instead of clipping them at the cell edge. Reset on
|
|
197
|
+
* resize so widening the window restores pills. */
|
|
198
|
+
@state()
|
|
199
|
+
private pillBudgets: Map<string, number> = new Map();
|
|
200
|
+
|
|
201
|
+
/** Pending rAF handle for the deferred pill-fit measure (0 when
|
|
202
|
+
* none is scheduled). */
|
|
203
|
+
private pillMeasureFrame = 0;
|
|
204
|
+
|
|
205
|
+
/** Watches the host's width so budgets recompute only when the
|
|
206
|
+
* geometry can actually change — never on scroll-driven re-renders,
|
|
207
|
+
* which would force layout reads for nothing. The host's width is
|
|
208
|
+
* page-layout-driven (folding pills can't change it), so observing
|
|
209
|
+
* it can't oscillate with the folding itself. */
|
|
210
|
+
private hostResizeObserver: ResizeObserver;
|
|
211
|
+
|
|
212
|
+
private lastHostWidth = -1;
|
|
213
|
+
|
|
214
|
+
/** Pending debounce timer for the post-resize budget recompute (0
|
|
215
|
+
* when none is scheduled). */
|
|
216
|
+
private pillSyncTimeout = 0;
|
|
217
|
+
|
|
218
|
+
/** Each pill cell's width as of its last budget computation, keyed
|
|
219
|
+
* like {@link pillBudgets}. A host resize only resets the cells
|
|
220
|
+
* whose width actually moved — when the table is already at its
|
|
221
|
+
* column minimums (scrolling horizontally), a window resize changes
|
|
222
|
+
* the host but not the cells, and nothing re-folds or flashes. */
|
|
223
|
+
private pillCellWidths: Map<string, number> = new Map();
|
|
224
|
+
|
|
225
|
+
public connectedCallback(): void {
|
|
226
|
+
super.connectedCallback();
|
|
227
|
+
this.hostResizeObserver = new ResizeObserver((entries) => {
|
|
228
|
+
const width = entries[entries.length - 1].contentRect.width;
|
|
229
|
+
// ignore height-only changes and sub-pixel noise
|
|
230
|
+
if (Math.abs(width - this.lastHostWidth) <= 1) return;
|
|
231
|
+
this.lastHostWidth = width;
|
|
232
|
+
// debounce the recompute to the end of the resize burst — while
|
|
233
|
+
// the drag is in flight, overflowing pills crop cleanly inside
|
|
234
|
+
// their row (.pills is overflow: hidden) and refold just once
|
|
235
|
+
window.clearTimeout(this.pillSyncTimeout);
|
|
236
|
+
this.pillSyncTimeout = window.setTimeout(() => {
|
|
237
|
+
this.pillSyncTimeout = 0;
|
|
238
|
+
this.syncPillCellWidths();
|
|
239
|
+
}, PILL_SYNC_DEBOUNCE);
|
|
240
|
+
});
|
|
241
|
+
this.hostResizeObserver.observe(this);
|
|
242
|
+
// Late web-font loads change pill text widths.
|
|
243
|
+
if (document.fonts && document.fonts.ready) {
|
|
244
|
+
document.fonts.ready.then(() => this.schedulePillMeasure());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** After the host's width changes, find the pill cells whose own
|
|
249
|
+
* width actually changed with it: reset those budgets to the full
|
|
250
|
+
* cap (the measure walks them back down to what now fits) and
|
|
251
|
+
* leave every other cell untouched. */
|
|
252
|
+
private syncPillCellWidths(): void {
|
|
253
|
+
const containers = this.shadowRoot.querySelectorAll('.pills[data-fit]');
|
|
254
|
+
let resetBudgets: Map<string, number> = null;
|
|
255
|
+
let needMeasure = false;
|
|
256
|
+
containers.forEach((el: Element) => {
|
|
257
|
+
const key = (el as HTMLElement).dataset.fit;
|
|
258
|
+
const cell = el.closest('td');
|
|
259
|
+
if (!cell) return;
|
|
260
|
+
const width = cell.clientWidth;
|
|
261
|
+
const last = this.pillCellWidths.get(key);
|
|
262
|
+
if (last != null && Math.abs(width - last) <= 1) return;
|
|
263
|
+
this.pillCellWidths.set(key, width);
|
|
264
|
+
needMeasure = true;
|
|
265
|
+
if (this.pillBudgets.has(key)) {
|
|
266
|
+
resetBudgets = resetBudgets || new Map(this.pillBudgets);
|
|
267
|
+
resetBudgets.delete(key);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
if (resetBudgets) {
|
|
271
|
+
this.pillBudgets = resetBudgets;
|
|
272
|
+
this.updateComplete.then(() => this.schedulePillMeasure());
|
|
273
|
+
} else if (needMeasure) {
|
|
274
|
+
this.schedulePillMeasure();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public disconnectedCallback(): void {
|
|
279
|
+
super.disconnectedCallback();
|
|
280
|
+
this.hostResizeObserver.disconnect();
|
|
281
|
+
window.clearTimeout(this.pillSyncTimeout);
|
|
282
|
+
this.pillSyncTimeout = 0;
|
|
283
|
+
if (this.pillMeasureFrame) {
|
|
284
|
+
cancelAnimationFrame(this.pillMeasureFrame);
|
|
285
|
+
this.pillMeasureFrame = 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
protected updated(changes: PropertyValues): void {
|
|
290
|
+
super.updated(changes);
|
|
291
|
+
// Only a new set of rows warrants a fresh measure — re-renders
|
|
292
|
+
// from scroll state (header shadows etc.) don't move any pills.
|
|
293
|
+
if (changes.has('items')) {
|
|
294
|
+
this.schedulePillMeasure();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private schedulePillMeasure(): void {
|
|
299
|
+
if (this.pillMeasureFrame) return;
|
|
300
|
+
this.pillMeasureFrame = requestAnimationFrame(() => {
|
|
301
|
+
this.pillMeasureFrame = 0;
|
|
302
|
+
this.measurePillFit();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Walk each pill row's budget down one pill per pass while its
|
|
307
|
+
* content overflows its box. Shrinking a budget re-renders (the
|
|
308
|
+
* hidden pill moves into the "+N" summary), which schedules the
|
|
309
|
+
* next measure — so a cell converges to the largest count that
|
|
310
|
+
* fits within a few frames. Budgets floor at zero: a cell too
|
|
311
|
+
* narrow for even one pill shows just the "+N" summary, whose
|
|
312
|
+
* tooltip still names everything. */
|
|
313
|
+
private measurePillFit(): void {
|
|
314
|
+
const containers = this.shadowRoot.querySelectorAll('.pills[data-fit]');
|
|
315
|
+
let next: Map<string, number> = null;
|
|
316
|
+
containers.forEach((el: Element) => {
|
|
317
|
+
const key = (el as HTMLElement).dataset.fit;
|
|
318
|
+
const budget = this.pillBudgets.get(key) ?? MAX_PILLS;
|
|
319
|
+
// record the cell width this budget was computed at, so a later
|
|
320
|
+
// resize can tell which cells actually moved
|
|
321
|
+
const cell = el.closest('td');
|
|
322
|
+
if (cell) {
|
|
323
|
+
this.pillCellWidths.set(key, cell.clientWidth);
|
|
324
|
+
}
|
|
325
|
+
// +1 tolerance for fractional layout rounding
|
|
326
|
+
if (budget > 0 && el.scrollWidth > el.clientWidth + 1) {
|
|
327
|
+
next = next || new Map(this.pillBudgets);
|
|
328
|
+
next.set(key, budget - 1);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
if (next) {
|
|
332
|
+
this.pillBudgets = next;
|
|
333
|
+
// keep converging after the fold re-renders — updated() no
|
|
334
|
+
// longer measures on every pass, so chain explicitly
|
|
335
|
+
this.updateComplete.then(() => this.schedulePillMeasure());
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The visible-pill budget for a cell — MAX_PILLS until
|
|
340
|
+
* measurement has folded it down. */
|
|
341
|
+
private pillBudget(item: Trigger, cellKey: string): number {
|
|
342
|
+
return this.pillBudgets.get(`${this.rowId(item)}:${cellKey}`) ?? MAX_PILLS;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
protected getRowIcon(item: Trigger): string | null {
|
|
346
|
+
return (Icon as any)[`trigger_${item?.type}`] || Icon.trigger;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Navigate a pill click through the SPA, suppressing the row's
|
|
350
|
+
* own click (which opens the update modal). Shared by the flow,
|
|
351
|
+
* channel, group, and contact pills. */
|
|
352
|
+
private handlePillClick(href: string, event: MouseEvent): void {
|
|
353
|
+
// Stop the click from bubbling to the row's modal handler.
|
|
354
|
+
event.stopPropagation();
|
|
355
|
+
// Guard the JSON-driven href against open-redirect, same as the
|
|
356
|
+
// row-click path in ContentList.handleRowClick.
|
|
357
|
+
if (!href || !this.isSafeHref(href)) return;
|
|
358
|
+
// Meta/ctrl-click opens a new tab, matching ordinary links and
|
|
359
|
+
// the row-click behavior.
|
|
360
|
+
if (event.metaKey || event.ctrlKey) {
|
|
361
|
+
window.open(href, '_blank');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.fireCustomEvent(CustomEventType.Redirected, { url: href });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
protected renderCell(
|
|
368
|
+
item: Trigger,
|
|
369
|
+
column: ContentListColumn
|
|
370
|
+
): TemplateResult | string {
|
|
371
|
+
switch (column.key) {
|
|
372
|
+
case 'trigger':
|
|
373
|
+
return this.renderTrigger(item);
|
|
374
|
+
case 'filters':
|
|
375
|
+
return this.renderFilters(item);
|
|
376
|
+
case 'flow':
|
|
377
|
+
return this.renderFlow(item);
|
|
378
|
+
case 'created_on':
|
|
379
|
+
return item.created_on
|
|
380
|
+
? html`<temba-date
|
|
381
|
+
value=${item.created_on}
|
|
382
|
+
display="timedate"
|
|
383
|
+
></temba-date>`
|
|
384
|
+
: EMPTY;
|
|
385
|
+
default:
|
|
386
|
+
return super.renderCell(item, column);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Overflow summary pill — "+N" for the pills past the visible
|
|
391
|
+
* cap, with the hidden names in its tooltip. Not clickable itself,
|
|
392
|
+
* so a click on it falls through to the row (the update modal),
|
|
393
|
+
* where the full set is editable. */
|
|
394
|
+
private renderOverflowPill(hiddenNames: string[]): TemplateResult | null {
|
|
395
|
+
if (!hiddenNames.length) return null;
|
|
396
|
+
return html`<temba-label
|
|
397
|
+
class="overflow"
|
|
398
|
+
type="neutral"
|
|
399
|
+
title=${hiddenNames.join(', ')}
|
|
400
|
+
>+${hiddenNames.length}</temba-label
|
|
401
|
+
>`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The trigger cell: every row leads with a word at the type-name
|
|
405
|
+
* weight — "Message" for keywords, "Scheduled" for schedules,
|
|
406
|
+
* "Referral" for referrals, or the type name itself for types with
|
|
407
|
+
* no details — followed by the muted per-type specifics. */
|
|
408
|
+
private renderTrigger(item: Trigger): TemplateResult | string {
|
|
409
|
+
switch (item.type) {
|
|
410
|
+
case 'keyword': {
|
|
411
|
+
const keywords = item.keywords || [];
|
|
412
|
+
if (!keywords.length) break;
|
|
413
|
+
const budget = this.pillBudget(item, 'keywords');
|
|
414
|
+
const visible = keywords.slice(0, budget);
|
|
415
|
+
return html`<span class="details">
|
|
416
|
+
<span class="type-name">Message</span>
|
|
417
|
+
<span class="lead-in"
|
|
418
|
+
>${item.match_type === 'O' ? 'matches' : 'starts with'}</span
|
|
419
|
+
>
|
|
420
|
+
<span class="pills" data-fit="${this.rowId(item)}:keywords">
|
|
421
|
+
${visible.map(
|
|
422
|
+
(k) =>
|
|
423
|
+
html`<temba-label
|
|
424
|
+
type="keyword"
|
|
425
|
+
class=${k.length > WIDE_KEYWORD_CHARS ? 'wide' : ''}
|
|
426
|
+
>${k}</temba-label
|
|
427
|
+
>`
|
|
428
|
+
)}
|
|
429
|
+
${this.renderOverflowPill(keywords.slice(budget))}
|
|
430
|
+
</span>
|
|
431
|
+
</span>`;
|
|
432
|
+
}
|
|
433
|
+
case 'schedule':
|
|
434
|
+
// a paused or exhausted schedule has no next fire — matching
|
|
435
|
+
// the legacy list's "is not scheduled" copy
|
|
436
|
+
return item.schedule?.next_fire && item.schedule?.display
|
|
437
|
+
? html`<span class="details">
|
|
438
|
+
<span class="type-name">Scheduled</span>
|
|
439
|
+
<span class="lead-in">${item.schedule.display}</span>
|
|
440
|
+
</span>`
|
|
441
|
+
: html`<span class="type-name">Not scheduled</span>`;
|
|
442
|
+
case 'referral':
|
|
443
|
+
if (!item.referrer_id) break;
|
|
444
|
+
return html`<span class="details">
|
|
445
|
+
<span class="type-name">Referral</span>
|
|
446
|
+
<span class="lead-in">from</span>
|
|
447
|
+
<span class="detail-text">${item.referrer_id}</span>
|
|
448
|
+
</span>`;
|
|
449
|
+
}
|
|
450
|
+
return html`<span class="type-name"
|
|
451
|
+
>${TYPE_NAMES[item.type] || item.type || EMPTY}</span
|
|
452
|
+
>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The filter pills scoping the trigger: the channel first (there
|
|
456
|
+
* is only ever one), then the contacts a scheduled trigger starts,
|
|
457
|
+
* the groups it's limited to, and — tinted red — the groups it
|
|
458
|
+
* excludes. At most the cell's measured budget (≤ {@link MAX_PILLS})
|
|
459
|
+
* renders; the rest fold into a "+N" summary pill so a crowded row
|
|
460
|
+
* never squeezes its pills down to bare icons or clips them. */
|
|
461
|
+
private renderFilters(item: Trigger): TemplateResult | string {
|
|
462
|
+
const channel = item.channel;
|
|
463
|
+
const filters: FilterPill[] = [];
|
|
464
|
+
if (channel) {
|
|
465
|
+
filters.push({
|
|
466
|
+
type: 'channel',
|
|
467
|
+
icon: channel.icon || Icon.channel,
|
|
468
|
+
name: channel.name,
|
|
469
|
+
href: `/channels/channel/read/${channel.uuid}/`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
for (const c of item.contacts || []) {
|
|
473
|
+
filters.push({
|
|
474
|
+
type: 'contact',
|
|
475
|
+
name: c.name,
|
|
476
|
+
href: `/contact/read/${c.uuid}/`
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
for (const g of item.groups || []) {
|
|
480
|
+
filters.push({
|
|
481
|
+
type: 'group',
|
|
482
|
+
icon: Icon.group_include,
|
|
483
|
+
name: g.name,
|
|
484
|
+
href: `/contact/group/${g.uuid}/`
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
for (const g of item.exclude_groups || []) {
|
|
488
|
+
filters.push({
|
|
489
|
+
type: 'group',
|
|
490
|
+
icon: Icon.group_exclude,
|
|
491
|
+
name: g.name,
|
|
492
|
+
href: `/contact/group/${g.uuid}/`,
|
|
493
|
+
exclude: true
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (!filters.length) {
|
|
497
|
+
return EMPTY;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const budget = this.pillBudget(item, 'filters');
|
|
501
|
+
const visible = filters.slice(0, budget);
|
|
502
|
+
// excluded names keep their meaning in the summary tooltip
|
|
503
|
+
const hidden = filters
|
|
504
|
+
.slice(budget)
|
|
505
|
+
.map((f) => (f.exclude ? `not ${f.name}` : f.name));
|
|
506
|
+
|
|
507
|
+
return html`<span class="pills" data-fit="${this.rowId(item)}:filters">
|
|
508
|
+
${visible.map(
|
|
509
|
+
(f) =>
|
|
510
|
+
html`<temba-label
|
|
511
|
+
class="${f.exclude ? 'exclude' : ''} ${f.name.length >
|
|
512
|
+
WIDE_PILL_CHARS
|
|
513
|
+
? 'wide'
|
|
514
|
+
: ''}"
|
|
515
|
+
type=${f.type}
|
|
516
|
+
icon=${ifDefined(f.icon)}
|
|
517
|
+
clickable
|
|
518
|
+
@click=${(e: MouseEvent) => this.handlePillClick(f.href, e)}
|
|
519
|
+
>${f.name}</temba-label
|
|
520
|
+
>`
|
|
521
|
+
)}
|
|
522
|
+
${this.renderOverflowPill(hidden)}
|
|
523
|
+
</span>`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private renderFlow(item: Trigger): TemplateResult | string {
|
|
527
|
+
const flow = item.flow;
|
|
528
|
+
if (!flow?.uuid) return EMPTY;
|
|
529
|
+
return html`<temba-label
|
|
530
|
+
type="flow"
|
|
531
|
+
primary
|
|
532
|
+
clickable
|
|
533
|
+
@click=${(e: MouseEvent) =>
|
|
534
|
+
this.handlePillClick(`/flow/editor/${flow.uuid}/`, e)}
|
|
535
|
+
>${flow.name}</temba-label
|
|
536
|
+
>`;
|
|
537
|
+
}
|
|
538
|
+
}
|