@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.
@@ -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
+ }