@nyaruka/temba-components 0.158.0 → 0.158.3

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,1298 @@
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { Icon } from '../Icons';
5
+ import { CustomEventType } from '../interfaces';
6
+ import { getUrl, postUrl, debounce } from '../utils';
7
+ import { designTokens } from '../styles/designTokens';
8
+
9
+ /** A single column in the list. Subclasses typically define a static
10
+ * set via {@link ContentList.columns}; consumers may also set it as
11
+ * an attribute / property for ad-hoc lists. */
12
+ export interface ContentListColumn {
13
+ key: string;
14
+ label?: string;
15
+ sortable?: boolean;
16
+ align?: 'left' | 'right' | 'center';
17
+ /** Explicit flex-basis (e.g. "120px" or "20%"). When omitted the
18
+ * cell uses `flex: <grow> 1 0` and shares remaining width. */
19
+ width?: string;
20
+ /** Flex grow factor — defaults to 1, set to 0 to keep a column
21
+ * sized strictly to its `width`. */
22
+ grow?: number;
23
+ }
24
+
25
+ /** A bulk action surfaced in the toolbar when one or more rows are
26
+ * selected. The host typically handles the action by listening for
27
+ * `temba-bulk-action` and POSTing as it sees fit. The label-toggle
28
+ * action is a special case — when `labelsEndpoint` is set, the
29
+ * component renders a dropdown of label checkboxes and POSTs the
30
+ * apply/remove directly to {@link ContentList.actionEndpoint},
31
+ * mirroring rapidpro's `runActionOnObjectRows('label', …)` flow. */
32
+ export interface ContentListBulkAction {
33
+ key: string;
34
+ label: string;
35
+ icon?: string;
36
+ destructive?: boolean;
37
+ /** GET endpoint returning `{ results: [{ uuid, name, count? }] }`.
38
+ * Setting this turns the action into a label-toggle dropdown
39
+ * instead of a fire-and-forget bulk-action event. */
40
+ labelsEndpoint?: string;
41
+ }
42
+
43
+ interface FetchResponse<T = any> {
44
+ results: T[];
45
+ count?: number;
46
+ next?: string;
47
+ previous?: string;
48
+ }
49
+
50
+ /**
51
+ * Generic JSON-driven list for CRUDL-style pages. Renders search +
52
+ * sortable column headers + multi-select rows + bulk-action toolbar
53
+ * + paged pagination, fully styled from the TextIt design tokens.
54
+ *
55
+ * Subclasses set `columns` / `bulkActions` / `valueKey` and override
56
+ * {@link renderCell} for non-trivial cells (pills, attachments,
57
+ * progress bars, etc.). The base class handles selection, sorting,
58
+ * search debouncing, pagination, URL state, and fetch lifecycle.
59
+ *
60
+ * No polling — list refresh is explicit (`refresh()` method or
61
+ * `refresh-key` attribute change). CRUDL pages should not auto-poll.
62
+ */
63
+ export class ContentList<T = any> extends RapidElement {
64
+ static get styles() {
65
+ return css`
66
+ ${designTokens}
67
+
68
+ :host {
69
+ display: block;
70
+ font-family: var(--font);
71
+ color: var(--text-1);
72
+ font-size: 13.5px;
73
+ }
74
+
75
+ /* Title row sits inside the panel at the top — title + subtitle
76
+ on the left, actions slot on the right. When rows are
77
+ selected the actions slot is replaced inline by bulk-action
78
+ chips so the toolbar stays in the same spot visually. */
79
+ .titlebar {
80
+ display: flex;
81
+ align-items: flex-start;
82
+ gap: var(--gap);
83
+ padding: 20px 0 16px 0;
84
+ }
85
+ .titles {
86
+ flex: 1 1 auto;
87
+ min-width: 0;
88
+ }
89
+ .title {
90
+ font-size: 15.5px;
91
+ font-weight: var(--w-semibold);
92
+ color: var(--text-1);
93
+ line-height: 1.3;
94
+ }
95
+ .subtitle {
96
+ font-size: 12.5px;
97
+ color: var(--text-3);
98
+ line-height: 1.3;
99
+ margin-top: 1px;
100
+ }
101
+ .actions {
102
+ flex: 0 0 auto;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 14px;
106
+ color: var(--text-2);
107
+ font-size: 13px;
108
+ }
109
+
110
+ /* Built-in action button (Search). Plain text + icon, no
111
+ border, host's slotted buttons can match this style or
112
+ bring their own. */
113
+ .action {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ gap: 6px;
117
+ cursor: pointer;
118
+ user-select: none;
119
+ padding: 6px 8px;
120
+ border-radius: var(--r-sm);
121
+ color: var(--text-2);
122
+ }
123
+ .action:hover {
124
+ background: var(--sunken);
125
+ color: var(--text-1);
126
+ }
127
+ .action temba-icon {
128
+ --icon-color: currentColor;
129
+ }
130
+
131
+ .bulk-action {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 6px;
135
+ padding: 6px 10px;
136
+ border-radius: var(--r-sm);
137
+ background: var(--accent-100);
138
+ color: var(--accent-800);
139
+ font-size: 12.5px;
140
+ cursor: pointer;
141
+ user-select: none;
142
+ }
143
+ .bulk-action:hover {
144
+ background: var(--accent-200);
145
+ }
146
+ .bulk-action.destructive {
147
+ background: var(--danger-bg);
148
+ color: var(--danger);
149
+ }
150
+ .bulk-action.destructive:hover {
151
+ background: color-mix(in oklab, var(--danger) 20%, white);
152
+ }
153
+ .bulk-action temba-icon {
154
+ --icon-color: currentColor;
155
+ }
156
+ .bulk-count {
157
+ font-weight: var(--w-medium);
158
+ color: var(--accent-800);
159
+ margin-right: 4px;
160
+ }
161
+
162
+ /* Label-toggle dropdown — temba-dropdown wraps the bulk-
163
+ action button, and the slotted content is a list of
164
+ per-label checkbox rows. The menu padding/width matches
165
+ the rapidpro pattern in short_pagination.html. */
166
+ .label-menu {
167
+ min-width: 220px;
168
+ max-height: 320px;
169
+ overflow-y: auto;
170
+ padding: 8px 4px;
171
+ font-size: 13px;
172
+ }
173
+ .label-menu-empty {
174
+ padding: 12px 16px;
175
+ color: var(--text-3);
176
+ font-size: 12.5px;
177
+ }
178
+ .lbl-menu {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 8px;
182
+ padding: 6px 12px;
183
+ border-radius: var(--r-sm);
184
+ cursor: pointer;
185
+ color: var(--text-1);
186
+ }
187
+ .lbl-menu:hover {
188
+ background: var(--accent-50);
189
+ }
190
+ .lbl-menu.pending {
191
+ background: var(--accent-50);
192
+ }
193
+ /* During an in-flight toggle, the other rows are blocked.
194
+ Keep them readable (no opacity dim) but disable hover and
195
+ cursor so the user can't fire conflicting POSTs. */
196
+ .lbl-menu.blocked,
197
+ .lbl-menu.blocked:hover {
198
+ cursor: not-allowed;
199
+ background: transparent;
200
+ color: var(--text-3);
201
+ }
202
+ .lbl-menu .lbl-name {
203
+ flex: 1 1 auto;
204
+ min-width: 0;
205
+ overflow: hidden;
206
+ text-overflow: ellipsis;
207
+ white-space: nowrap;
208
+ }
209
+ .lbl-menu temba-loading {
210
+ flex: 0 0 auto;
211
+ }
212
+
213
+ /* Inline search bar — slides below the title row inside the
214
+ panel when the search trigger is active. Single-line input
215
+ with a leading icon, no border, --sunken background. */
216
+ .searchbar {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 8px;
220
+ padding: 6px 12px;
221
+ margin: 0 0 12px 0;
222
+ background: var(--sunken);
223
+ border-radius: var(--r-sm);
224
+ color: var(--text-3);
225
+ }
226
+ .searchbar input {
227
+ flex: 1 1 auto;
228
+ border: 0;
229
+ background: transparent;
230
+ outline: 0;
231
+ font: inherit;
232
+ color: var(--text-1);
233
+ min-width: 0;
234
+ }
235
+ .searchbar input::placeholder {
236
+ color: var(--text-3);
237
+ }
238
+ .searchbar .clear {
239
+ cursor: pointer;
240
+ color: var(--text-3);
241
+ padding: 2px;
242
+ }
243
+ .searchbar .clear:hover {
244
+ color: var(--text-2);
245
+ }
246
+
247
+ /* Card panel — surface white wrapping everything from title
248
+ to footer. Soft shadow + radius gives it the contained-card
249
+ feel from the styleguide. The 20px horizontal padding is
250
+ what insets the header, rows, and footer from the card
251
+ edges so the row strips (and their hover wash) sit on a
252
+ clear margin instead of bleeding to the card chrome. */
253
+ .panel {
254
+ background: var(--surface);
255
+ border-radius: var(--r);
256
+ overflow: hidden;
257
+ box-shadow: var(--shadow-1);
258
+ padding: 0 20px;
259
+ }
260
+
261
+ /* Header row sits inside the panel below the titlebar. The
262
+ separators above/below the header are drawn via pseudo-
263
+ elements so they inset 20px from the card edges instead
264
+ of bleeding full-width — same with the row separators
265
+ below. The header background stays untinted; only weight
266
+ + uppercase distinguish it from data rows. */
267
+ /* Full-bleed rule between titlebar/searchbar and the header
268
+ row. Negative horizontal margin escapes the panel's 20px
269
+ padding so the line reaches the card chrome on both sides
270
+ — the rest of the table (rows, lines, hover wash) stays
271
+ inset. */
272
+ .header-rule {
273
+ height: 1px;
274
+ background: var(--border);
275
+ margin: 0 -20px;
276
+ }
277
+
278
+ .header {
279
+ position: relative;
280
+ display: flex;
281
+ align-items: center;
282
+ min-height: 36px;
283
+ padding: 0 12px;
284
+ color: var(--text-3);
285
+ font-size: 11px;
286
+ font-weight: var(--w-medium);
287
+ text-transform: uppercase;
288
+ letter-spacing: 0.06em;
289
+ }
290
+ .header::after {
291
+ content: '';
292
+ position: absolute;
293
+ left: 0;
294
+ right: 0;
295
+ bottom: 0;
296
+ height: 1px;
297
+ background: var(--border);
298
+ }
299
+
300
+ /* Data rows live inside the panel's 20px horizontal padding,
301
+ with an extra 12px of lead padding so the checkbox sits
302
+ off the row's left edge. The hover/selected wash paints
303
+ the full row box (inset by the panel), and the bottom
304
+ separator spans the same width. */
305
+ .row {
306
+ position: relative;
307
+ display: flex;
308
+ align-items: center;
309
+ min-height: 44px;
310
+ padding: 0 12px;
311
+ color: var(--text-1);
312
+ cursor: default;
313
+ }
314
+ .row::after {
315
+ content: '';
316
+ position: absolute;
317
+ bottom: 0;
318
+ left: 0;
319
+ right: 0;
320
+ height: 1px;
321
+ background: var(--border);
322
+ }
323
+ .row:last-child::after {
324
+ display: none;
325
+ }
326
+ .row:hover {
327
+ background: var(--accent-50);
328
+ }
329
+ .row.selected {
330
+ background: var(--accent-50);
331
+ }
332
+ .row.clickable {
333
+ cursor: pointer;
334
+ }
335
+
336
+ .cell,
337
+ .head-cell {
338
+ padding: 0 8px;
339
+ flex: 1 1 0;
340
+ min-width: 0;
341
+ overflow: hidden;
342
+ text-overflow: ellipsis;
343
+ white-space: nowrap;
344
+ }
345
+ .cell.wrap,
346
+ .head-cell.wrap {
347
+ white-space: normal;
348
+ }
349
+ .cell.right,
350
+ .head-cell.right {
351
+ text-align: right;
352
+ justify-content: flex-end;
353
+ }
354
+ .cell.center,
355
+ .head-cell.center {
356
+ text-align: center;
357
+ justify-content: center;
358
+ }
359
+
360
+ /* temba-checkbox sizes its icon in em, so the parent's
361
+ font-size dictates the visual scale. The header row uses
362
+ a smaller font-size for its uppercase labels — without
363
+ this override, the header's select-all checkbox would
364
+ render smaller than the row checkboxes. Pin the cell's
365
+ font-size so all checkboxes match regardless of parent. */
366
+ .check-cell {
367
+ flex: 0 0 auto;
368
+ padding: 0 6px 0 0;
369
+ display: flex;
370
+ align-items: center;
371
+ font-size: 13.5px;
372
+ cursor: pointer;
373
+ --icon-color: var(--text-3);
374
+ }
375
+ /* The inner temba-checkbox is purely a visual indicator —
376
+ the cell-level @click is the single source of truth for
377
+ toggling selection. Disabling pointer events on the
378
+ checkbox prevents its internal click handler from firing
379
+ a second toggle on the same user click. */
380
+ .check-cell temba-checkbox {
381
+ pointer-events: none;
382
+ }
383
+ .row.selected .check-cell {
384
+ --icon-color: var(--accent-700);
385
+ }
386
+
387
+ .head-cell.sortable {
388
+ cursor: pointer;
389
+ user-select: none;
390
+ display: inline-flex;
391
+ align-items: center;
392
+ gap: 4px;
393
+ }
394
+ .head-cell.sortable:hover {
395
+ color: var(--text-2);
396
+ }
397
+ .head-cell.sortable temba-icon {
398
+ --icon-color: var(--text-3);
399
+ opacity: 0.55;
400
+ }
401
+ .head-cell.sortable.active temba-icon {
402
+ --icon-color: var(--accent-700);
403
+ opacity: 1;
404
+ }
405
+ .head-cell.sortable.active {
406
+ color: var(--accent-700);
407
+ }
408
+
409
+ /* Leading icon column — small entity-type icon shared by
410
+ every row in the list (e.g. campaign clock-refresh,
411
+ contact silhouette, flow type icon). Subclasses override
412
+ {@link getRowIcon} to return a name; if null the column
413
+ collapses. */
414
+ .icon-cell {
415
+ flex: 0 0 auto;
416
+ padding: 0 8px 0 0;
417
+ display: flex;
418
+ align-items: center;
419
+ --icon-color: var(--text-3);
420
+ }
421
+ .row.selected .icon-cell {
422
+ --icon-color: var(--accent-700);
423
+ }
424
+
425
+ .empty,
426
+ .loading {
427
+ padding: 40px var(--pad);
428
+ text-align: center;
429
+ color: var(--text-3);
430
+ }
431
+
432
+ /* Footer: plain "1–N of Total" count on the left, chevron-
433
+ only paging buttons on the right. No borders, no labels —
434
+ minimal as the styleguide. */
435
+ .footer {
436
+ display: flex;
437
+ align-items: center;
438
+ justify-content: space-between;
439
+ padding: 12px 0;
440
+ color: var(--text-3);
441
+ font-size: 12.5px;
442
+ }
443
+
444
+ .pager {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 4px;
448
+ }
449
+
450
+ .page-btn {
451
+ display: inline-flex;
452
+ align-items: center;
453
+ justify-content: center;
454
+ width: 24px;
455
+ height: 24px;
456
+ border-radius: var(--r-sm);
457
+ color: var(--text-3);
458
+ cursor: pointer;
459
+ user-select: none;
460
+ }
461
+ .page-btn:hover {
462
+ background: var(--sunken);
463
+ color: var(--text-1);
464
+ }
465
+ .page-btn[disabled],
466
+ .page-btn[disabled]:hover {
467
+ opacity: 0.35;
468
+ cursor: not-allowed;
469
+ background: transparent;
470
+ color: var(--text-3);
471
+ }
472
+ .page-btn temba-icon {
473
+ --icon-color: currentColor;
474
+ }
475
+
476
+ /* Status pill: small rounded chip with a leading colored
477
+ dot. Subclasses use {@link renderStatusPill} to surface
478
+ per-row state (active/pending/stopped/archived/etc.). */
479
+ .status-pill {
480
+ display: inline-flex;
481
+ align-items: center;
482
+ gap: 6px;
483
+ padding: 2px 10px 2px 8px;
484
+ border-radius: 999px;
485
+ font-size: 11.5px;
486
+ font-weight: var(--w-medium);
487
+ line-height: 1.4;
488
+ }
489
+ .status-pill::before {
490
+ content: '';
491
+ width: 6px;
492
+ height: 6px;
493
+ border-radius: 50%;
494
+ background: currentColor;
495
+ }
496
+ .status-active {
497
+ background: var(--success-bg);
498
+ color: var(--success);
499
+ }
500
+ .status-pending {
501
+ background: var(--info-bg);
502
+ color: var(--info);
503
+ }
504
+ .status-stopped,
505
+ .status-warning {
506
+ background: var(--warning-bg);
507
+ color: var(--warning);
508
+ }
509
+ .status-archived,
510
+ .status-neutral {
511
+ background: var(--neutral-bg);
512
+ color: var(--neutral);
513
+ }
514
+ .status-error {
515
+ background: var(--danger-bg);
516
+ color: var(--danger);
517
+ }
518
+ `;
519
+ }
520
+
521
+ /** JSON endpoint URL. The component appends `page`, `sort`, and
522
+ * `search` params. Response must be `{ results, count }` (plus
523
+ * optional `next` / `previous` for parity with api/v2). */
524
+ @property({ type: String })
525
+ endpoint: string;
526
+
527
+ /** Column definitions. Subclasses set this in the constructor;
528
+ * consumers may also override at the element level. */
529
+ @property({ type: Array, attribute: false })
530
+ columns: ContentListColumn[] = [];
531
+
532
+ /** Bulk actions surfaced in the toolbar when rows are selected. */
533
+ @property({ type: Array, attribute: false })
534
+ bulkActions: ContentListBulkAction[] = [];
535
+
536
+ /** Data key used to identify each row (default `uuid`). */
537
+ @property({ type: String })
538
+ valueKey = 'uuid';
539
+
540
+ @property({ type: Number })
541
+ pageSize = 50;
542
+
543
+ @property({ type: Boolean })
544
+ searchable = true;
545
+
546
+ /** When true, multi-select checkboxes render in the first column. */
547
+ @property({ type: Boolean })
548
+ selectable = true;
549
+
550
+ /** When true, sort/search/page state is reflected to the URL via
551
+ * `history.pushState` so the page is deep-linkable and back/forward
552
+ * navigates between list states. Off by default — opt in. */
553
+ @property({ type: Boolean })
554
+ urlState = false;
555
+
556
+ /** Prefix for URL parameter names — set this when multiple lists
557
+ * share a page (e.g. `messages` → `?messages_page=2&messages_sort=...`). */
558
+ @property({ type: String })
559
+ urlParamPrefix = '';
560
+
561
+ /** Placeholder for the search input. */
562
+ @property({ type: String })
563
+ searchPlaceholder = 'Search';
564
+
565
+ /** Page-level title rendered above the panel. Either set this or
566
+ * slot custom content via `<div slot="title">…</div>`. */
567
+ @property({ type: String, attribute: 'list-title' })
568
+ listTitle = '';
569
+
570
+ /** Smaller subtitle below the title. */
571
+ @property({ type: String })
572
+ subtitle = '';
573
+
574
+ /** Message shown when the list is empty. */
575
+ @property({ type: String })
576
+ emptyMessage = 'Nothing to show';
577
+
578
+ /** Bump to force a refetch — useful after a bulk action so the host
579
+ * can re-pull from the server. */
580
+ @property({ type: String })
581
+ refreshKey = '';
582
+
583
+ /** URL the component POSTs bulk-action changes to (currently
584
+ * label-toggle). Form-data shape mirrors rapidpro's smartmin
585
+ * `BulkActionMixin`: `action=label`, `objects[]=<id>`,
586
+ * `label=<uuid>`, `add=true|false`. */
587
+ @property({ type: String, attribute: 'action-endpoint' })
588
+ actionEndpoint = '';
589
+
590
+ @state()
591
+ protected items: T[] = [];
592
+
593
+ @state()
594
+ protected total = 0;
595
+
596
+ @state()
597
+ protected page = 1;
598
+
599
+ /** Sort key; prefix with `-` for descending. Empty = server default. */
600
+ @state()
601
+ protected sort = '';
602
+
603
+ @state()
604
+ protected search = '';
605
+
606
+ @state()
607
+ protected loading = false;
608
+
609
+ @state()
610
+ protected selectedIds: Set<string> = new Set();
611
+
612
+ /** Whether the inline search input is expanded. The "Search"
613
+ * action button toggles it; the styleguide hides the input until
614
+ * the user asks for it so the toolbar stays clean. */
615
+ @state()
616
+ protected searchOpen = false;
617
+
618
+ /** Cache of labels fetched per label-toggle action key.
619
+ * Populated lazily the first time a label dropdown opens. */
620
+ @state()
621
+ protected labelsByActionKey: { [key: string]: any[] } = {};
622
+
623
+ /** Uuid of the label currently being toggled. While set, the
624
+ * dropdown's other toggles are blocked so the user can't fire
625
+ * conflicting POSTs before the server confirms + the list
626
+ * re-fetches. */
627
+ @state()
628
+ protected pendingLabel: string | null = null;
629
+
630
+ private pending: AbortController = null;
631
+ private debouncedFetch: () => void;
632
+ private popstateHandler: () => void;
633
+
634
+ constructor() {
635
+ super();
636
+ this.debouncedFetch = debounce(() => this.fetchPage(), 250);
637
+ }
638
+
639
+ public connectedCallback(): void {
640
+ super.connectedCallback();
641
+ if (this.urlState) {
642
+ this.readUrlState();
643
+ this.popstateHandler = () => {
644
+ this.readUrlState();
645
+ this.fetchPage();
646
+ };
647
+ window.addEventListener('popstate', this.popstateHandler);
648
+ }
649
+ }
650
+
651
+ public disconnectedCallback(): void {
652
+ if (this.popstateHandler) {
653
+ window.removeEventListener('popstate', this.popstateHandler);
654
+ }
655
+ if (this.pending) {
656
+ this.pending.abort();
657
+ }
658
+ super.disconnectedCallback();
659
+ }
660
+
661
+ protected updated(changes: PropertyValues): void {
662
+ super.updated(changes);
663
+ // Only watch endpoint and refreshKey here — both are typically
664
+ // set externally and have no other handler that already fires a
665
+ // fetch. Sort/page/search are mutated by internal handlers that
666
+ // call fetchPage (directly or via debouncedFetch) themselves, so
667
+ // tracking them here would double-fire the request.
668
+ if ((changes.has('endpoint') || changes.has('refreshKey')) && this.endpoint) {
669
+ this.fetchPage();
670
+ }
671
+ }
672
+
673
+ /** Read sort/page/search from the URL on first load / popstate. */
674
+ private readUrlState(): void {
675
+ const params = new URLSearchParams(window.location.search);
676
+ const k = (name: string) =>
677
+ this.urlParamPrefix ? `${this.urlParamPrefix}_${name}` : name;
678
+ this.search = params.get(k('search')) || '';
679
+ this.sort = params.get(k('sort')) || '';
680
+ const pageParam = parseInt(params.get(k('page')) || '1', 10);
681
+ this.page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
682
+ }
683
+
684
+ /** Push current sort/page/search to the URL. `replace` is true while
685
+ * the user is typing in the search box (don't pollute history). */
686
+ private writeUrlState(replace = false): void {
687
+ if (!this.urlState) return;
688
+ const params = new URLSearchParams(window.location.search);
689
+ const k = (name: string) =>
690
+ this.urlParamPrefix ? `${this.urlParamPrefix}_${name}` : name;
691
+
692
+ const setOrDelete = (name: string, value: string) => {
693
+ if (value) params.set(name, value);
694
+ else params.delete(name);
695
+ };
696
+ setOrDelete(k('search'), this.search);
697
+ setOrDelete(k('sort'), this.sort);
698
+ setOrDelete(k('page'), this.page > 1 ? String(this.page) : '');
699
+
700
+ const qs = params.toString();
701
+ const url = window.location.pathname + (qs ? '?' + qs : '');
702
+ if (replace) {
703
+ window.history.replaceState({}, '', url);
704
+ } else {
705
+ window.history.pushState({}, '', url);
706
+ }
707
+ }
708
+
709
+ /** Build the request URL by appending sort/search/page params to
710
+ * the configured endpoint. */
711
+ private buildRequestUrl(): string {
712
+ const url = new URL(this.endpoint, window.location.origin);
713
+ if (this.search) url.searchParams.set('search', this.search);
714
+ if (this.sort) url.searchParams.set('sort', this.sort);
715
+ if (this.page > 1) url.searchParams.set('page', String(this.page));
716
+ if (this.pageSize !== 50)
717
+ url.searchParams.set('page_size', String(this.pageSize));
718
+ return url.pathname + url.search;
719
+ }
720
+
721
+ private async fetchPage(): Promise<void> {
722
+ if (!this.endpoint) return;
723
+ if (this.pending) this.pending.abort();
724
+ const controller = new AbortController();
725
+ this.pending = controller;
726
+ this.loading = true;
727
+ try {
728
+ const response = await getUrl(this.buildRequestUrl(), controller);
729
+ const data = (response.json || {}) as FetchResponse<T>;
730
+ this.items = data.results || [];
731
+ this.total = data.count ?? this.items.length;
732
+ // drop any selected ids that aren't visible anymore — selection
733
+ // is per-page, not cross-page, so users don't accidentally bulk
734
+ // act on rows they can't see.
735
+ const visible = new Set(this.items.map((i) => this.rowId(i)));
736
+ const next = new Set<string>();
737
+ this.selectedIds.forEach((id) => {
738
+ if (visible.has(id)) next.add(id);
739
+ });
740
+ this.selectedIds = next;
741
+ } catch (err) {
742
+ // aborted or failed; leave items as-is and let the caller see
743
+ // the empty/error state via console — no toast to keep the
744
+ // component dependency-free.
745
+ if ((err as DOMException)?.name !== 'AbortError') {
746
+ // eslint-disable-next-line no-console
747
+ console.error('ContentList fetch failed', err);
748
+ }
749
+ } finally {
750
+ if (this.pending === controller) {
751
+ this.pending = null;
752
+ this.loading = false;
753
+ this.fireCustomEvent(CustomEventType.FetchComplete);
754
+ }
755
+ }
756
+ }
757
+
758
+ /** Public API — programmatic refresh, mirrors `refreshKey` bump. */
759
+ public refresh(): void {
760
+ this.fetchPage();
761
+ }
762
+
763
+ /** Identity helper — uses the `valueKey` to pull a stable id from
764
+ * the row, falling back to JSON.stringify for objects without one. */
765
+ protected rowId(item: T): string {
766
+ const v = (item as any)?.[this.valueKey];
767
+ return v != null ? String(v) : JSON.stringify(item);
768
+ }
769
+
770
+ /** Override in subclasses to customize per-column rendering. The
771
+ * default reads `item[column.key]` and renders as text. */
772
+ protected renderCell(
773
+ item: T,
774
+ column: ContentListColumn
775
+ ): TemplateResult | string {
776
+ const value = (item as any)?.[column.key];
777
+ if (value == null) return '';
778
+ return String(value);
779
+ }
780
+
781
+ /** Override in subclasses to make rows navigate on click. Return
782
+ * a URL to navigate, or null to leave the click as event-only. */
783
+ protected getRowHref(_item: T): string | null {
784
+ return null;
785
+ }
786
+
787
+ private handleSearchInput(event: any): void {
788
+ this.search = event.target.value || '';
789
+ this.page = 1;
790
+ this.writeUrlState(true);
791
+ this.debouncedFetch();
792
+ }
793
+
794
+ private handleSortClick(column: ContentListColumn): void {
795
+ if (!column.sortable) return;
796
+ if (this.sort === column.key) {
797
+ this.sort = '-' + column.key;
798
+ } else if (this.sort === '-' + column.key) {
799
+ this.sort = '';
800
+ } else {
801
+ this.sort = column.key;
802
+ }
803
+ this.page = 1;
804
+ this.writeUrlState();
805
+ this.fetchPage();
806
+ }
807
+
808
+ private handleRowClick(item: T, event: MouseEvent): void {
809
+ // Ignore clicks originating from the checkbox cell so toggling
810
+ // selection doesn't double as navigation.
811
+ const path = event.composedPath();
812
+ if (path.some((n: any) => n?.classList?.contains?.('check-cell'))) {
813
+ return;
814
+ }
815
+ this.fireCustomEvent(CustomEventType.RowClick, { item });
816
+ const href = this.getRowHref(item);
817
+ if (href && this.isSafeHref(href)) {
818
+ window.location.href = href;
819
+ }
820
+ }
821
+
822
+ /** Guard against open-redirect: row hrefs come from JSON-driven
823
+ * subclasses and could contain externally-influenced values. Only
824
+ * permit same-origin navigation — absolute URLs must match the
825
+ * current origin, relative URLs must be path-only (starting with
826
+ * `/` and not `//`, which would be protocol-relative). */
827
+ private isSafeHref(href: string): boolean {
828
+ if (typeof href !== 'string' || href.length === 0) return false;
829
+ // Reject protocol-relative URLs ("//evil.com/...") and any
830
+ // scheme-prefixed URL that isn't same-origin.
831
+ if (href.startsWith('//')) return false;
832
+ if (href.startsWith('/')) return true;
833
+ try {
834
+ const url = new URL(href, window.location.origin);
835
+ return url.origin === window.location.origin;
836
+ } catch {
837
+ return false;
838
+ }
839
+ }
840
+
841
+ private handleRowToggle(item: T): void {
842
+ const id = this.rowId(item);
843
+ const next = new Set(this.selectedIds);
844
+ if (next.has(id)) next.delete(id);
845
+ else next.add(id);
846
+ this.selectedIds = next;
847
+ this.fireCustomEvent(CustomEventType.SelectionChange, {
848
+ ids: Array.from(next)
849
+ });
850
+ }
851
+
852
+ private handleSelectAll(): void {
853
+ const allIds = this.items.map((i) => this.rowId(i));
854
+ const allSelected =
855
+ allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
856
+ this.selectedIds = allSelected ? new Set() : new Set(allIds);
857
+ this.fireCustomEvent(CustomEventType.SelectionChange, {
858
+ ids: Array.from(this.selectedIds)
859
+ });
860
+ }
861
+
862
+ private handleBulkAction(action: ContentListBulkAction): void {
863
+ this.fireCustomEvent(CustomEventType.BulkAction, {
864
+ action: action.key,
865
+ ids: Array.from(this.selectedIds)
866
+ });
867
+ }
868
+
869
+ private handlePage(delta: number): void {
870
+ const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
871
+ const next = Math.min(lastPage, Math.max(1, this.page + delta));
872
+ if (next !== this.page) {
873
+ this.page = next;
874
+ this.writeUrlState();
875
+ this.fetchPage();
876
+ }
877
+ }
878
+
879
+ private renderTitlebar(): TemplateResult {
880
+ const selectionCount = this.selectedIds.size;
881
+ const bulkVisible = selectionCount > 0 && this.bulkActions.length > 0;
882
+ return html`
883
+ <div class="titlebar">
884
+ <div class="titles">
885
+ <div class="title">
886
+ <slot name="title">${this.listTitle}</slot>
887
+ </div>
888
+ ${this.subtitle || this.querySelector('[slot="subtitle"]')
889
+ ? html`<div class="subtitle">
890
+ <slot name="subtitle">${this.subtitle}</slot>
891
+ </div>`
892
+ : null}
893
+ </div>
894
+ <div class="actions">
895
+ ${bulkVisible
896
+ ? html`
897
+ <span class="bulk-count">${selectionCount} selected</span>
898
+ ${this.bulkActions.map((a) => this.renderBulkAction(a))}
899
+ `
900
+ : html`
901
+ ${this.searchable && !this.searchOpen
902
+ ? html`
903
+ <span class="action" @click=${() => this.toggleSearch()}>
904
+ <temba-icon
905
+ name=${Icon.search}
906
+ size="0.95"
907
+ ></temba-icon>
908
+ Search
909
+ </span>
910
+ `
911
+ : null}
912
+ <slot name="actions"></slot>
913
+ `}
914
+ </div>
915
+ </div>
916
+ ${this.searchable && this.searchOpen
917
+ ? html`
918
+ <div class="searchbar">
919
+ <temba-icon name=${Icon.search} size="0.95"></temba-icon>
920
+ <input
921
+ type="text"
922
+ placeholder=${this.searchPlaceholder}
923
+ .value=${this.search}
924
+ @input=${this.handleSearchInput}
925
+ autofocus
926
+ />
927
+ ${this.search
928
+ ? html`<span class="clear" @click=${() => this.clearSearch()}>
929
+ <temba-icon name=${Icon.close} size="0.85"></temba-icon>
930
+ </span>`
931
+ : null}
932
+ </div>
933
+ `
934
+ : null}
935
+ `;
936
+ }
937
+
938
+ private renderBulkAction(action: ContentListBulkAction): TemplateResult {
939
+ if (action.labelsEndpoint) {
940
+ return this.renderLabelDropdown(action);
941
+ }
942
+ return html`
943
+ <span
944
+ class="bulk-action ${action.destructive ? 'destructive' : ''}"
945
+ @click=${() => this.handleBulkAction(action)}
946
+ >
947
+ ${action.icon
948
+ ? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
949
+ : null}
950
+ ${action.label}
951
+ </span>
952
+ `;
953
+ }
954
+
955
+ private renderLabelDropdown(action: ContentListBulkAction): TemplateResult {
956
+ const labels = this.labelsByActionKey[action.key] || [];
957
+ return html`
958
+ <temba-dropdown
959
+ class="label-dropdown"
960
+ @temba-opened=${() => this.handleLabelDropdownOpened(action)}
961
+ >
962
+ <span
963
+ slot="toggle"
964
+ class="bulk-action ${action.destructive ? 'destructive' : ''}"
965
+ >
966
+ ${action.icon
967
+ ? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
968
+ : null}
969
+ ${action.label}
970
+ </span>
971
+ <div slot="dropdown" class="label-menu">
972
+ ${labels.length === 0
973
+ ? html`<div class="label-menu-empty">Loading&hellip;</div>`
974
+ : labels.map((label) => this.renderLabelOption(label))}
975
+ </div>
976
+ </temba-dropdown>
977
+ `;
978
+ }
979
+
980
+ private renderLabelOption(label: any): TemplateResult {
981
+ const state = this.computeLabelState(label.uuid);
982
+ const isPending = this.pendingLabel === label.uuid;
983
+ const isBlocked = this.pendingLabel !== null && !isPending;
984
+ return html`
985
+ <div
986
+ class="lbl-menu ${isPending ? 'pending' : ''} ${isBlocked
987
+ ? 'blocked'
988
+ : ''}"
989
+ @click=${(e: MouseEvent) => {
990
+ e.stopPropagation();
991
+ if (this.pendingLabel !== null) return;
992
+ this.toggleLabel(label, state);
993
+ }}
994
+ >
995
+ <temba-checkbox
996
+ size="1.1"
997
+ ?checked=${state === 'all'}
998
+ ?partial=${state === 'some'}
999
+ ></temba-checkbox>
1000
+ <span class="lbl-name">${label.name}</span>
1001
+ ${isPending
1002
+ ? html`<temba-loading units="3" size="6"></temba-loading>`
1003
+ : null}
1004
+ </div>
1005
+ `;
1006
+ }
1007
+
1008
+ private async handleLabelDropdownOpened(
1009
+ action: ContentListBulkAction
1010
+ ): Promise<void> {
1011
+ if (this.labelsByActionKey[action.key] || !action.labelsEndpoint) return;
1012
+ try {
1013
+ const response = await getUrl(action.labelsEndpoint);
1014
+ const labels = response.json?.results || [];
1015
+ this.labelsByActionKey = {
1016
+ ...this.labelsByActionKey,
1017
+ [action.key]: labels
1018
+ };
1019
+ } catch (err) {
1020
+ // eslint-disable-next-line no-console
1021
+ console.error('failed to fetch labels', err);
1022
+ }
1023
+ }
1024
+
1025
+ /** Compute the tri-state across the selected rows for a given
1026
+ * label uuid: 'all' if every selected row has it, 'some' if at
1027
+ * least one but not all do, 'none' otherwise. */
1028
+ private computeLabelState(labelUuid: string): 'none' | 'some' | 'all' {
1029
+ const selected = this.items.filter((item) =>
1030
+ this.selectedIds.has(this.rowId(item))
1031
+ );
1032
+ if (selected.length === 0) return 'none';
1033
+ const withLabel = selected.filter((item) =>
1034
+ ((item as any).labels || []).some((l: any) => l.uuid === labelUuid)
1035
+ );
1036
+ if (withLabel.length === 0) return 'none';
1037
+ if (withLabel.length === selected.length) return 'all';
1038
+ return 'some';
1039
+ }
1040
+
1041
+ /** Toggle a label across the currently-selected rows. Mirrors
1042
+ * rapidpro's `labelObjectRows` semantics: if every selected row
1043
+ * already has the label, we're removing; otherwise we're adding.
1044
+ *
1045
+ * No optimistic local update — if the list is filtered (e.g. a
1046
+ * view showing only messages with this label), removing the label
1047
+ * means the row no longer belongs in the view, and the only
1048
+ * correct thing to do is re-fetch from the server and let the
1049
+ * filtered result decide which rows stay. We POST first, then
1050
+ * refresh once the server confirms. The `pendingLabel` state
1051
+ * blocks further toggles until the round-trip completes. */
1052
+ private async toggleLabel(label: any, state: string): Promise<void> {
1053
+ if (this.pendingLabel !== null) return;
1054
+ const add = state !== 'all';
1055
+ const originalSelectedIds = Array.from(this.selectedIds);
1056
+ this.pendingLabel = label.uuid;
1057
+
1058
+ if (this.actionEndpoint) {
1059
+ // application/x-www-form-urlencoded matches what Django's
1060
+ // smartmin `BulkActionMixin` reads from `request.POST`, and
1061
+ // is trivial to parse server-side (URLSearchParams) without
1062
+ // pulling in a multipart parser for the demo mock.
1063
+ const params = new URLSearchParams();
1064
+ params.append('action', 'label');
1065
+ params.append('label', label.uuid);
1066
+ if (!add) params.append('add', 'false');
1067
+ originalSelectedIds.forEach((id) => params.append('objects', id));
1068
+ try {
1069
+ await postUrl(this.actionEndpoint, params);
1070
+ // Re-fetch the page so a filtered view (e.g. label-filter)
1071
+ // drops rows that no longer match.
1072
+ await this.fetchPage();
1073
+ // Re-check the ids we were operating on. Items that survived
1074
+ // the refresh stay selected; items the server filtered out
1075
+ // (label removed → no longer matches the view) are absent
1076
+ // from `this.items` and won't be re-selected. Mirrors
1077
+ // rapidpro's `recheckIds()` after a `spaPost`.
1078
+ this.recheckSelection(originalSelectedIds);
1079
+ } catch (err) {
1080
+ // eslint-disable-next-line no-console
1081
+ console.error('label toggle POST failed', err);
1082
+ }
1083
+ }
1084
+
1085
+ this.pendingLabel = null;
1086
+
1087
+ this.fireCustomEvent(CustomEventType.BulkAction, {
1088
+ action: 'label',
1089
+ ids: originalSelectedIds,
1090
+ label: label.uuid,
1091
+ add
1092
+ });
1093
+ }
1094
+
1095
+ /** Re-apply a selection set against the current `items`. Used
1096
+ * after a refresh that follows a bulk action — only ids whose
1097
+ * rows are still visible stay selected. */
1098
+ private recheckSelection(ids: string[]): void {
1099
+ const visible = new Set(this.items.map((i) => this.rowId(i)));
1100
+ this.selectedIds = new Set(ids.filter((id) => visible.has(id)));
1101
+ }
1102
+
1103
+ private toggleSearch(): void {
1104
+ this.searchOpen = !this.searchOpen;
1105
+ if (!this.searchOpen && this.search) {
1106
+ this.clearSearch();
1107
+ }
1108
+ }
1109
+
1110
+ private clearSearch(): void {
1111
+ this.search = '';
1112
+ this.page = 1;
1113
+ this.writeUrlState(true);
1114
+ this.fetchPage();
1115
+ }
1116
+
1117
+ /** Render a status pill — convenience for subclasses. The
1118
+ * `kind` keys match the `.status-{kind}` classes defined in
1119
+ * ContentList styles (active / pending / stopped / archived /
1120
+ * warning / neutral / error). */
1121
+ protected renderStatusPill(kind: string, label: string): TemplateResult {
1122
+ return html`<span class="status-pill status-${kind}">${label}</span>`;
1123
+ }
1124
+
1125
+ /** Optional leading icon name for each row (e.g. the campaign
1126
+ * clock-refresh in the styleguide). Override in subclasses;
1127
+ * return `null` to skip the leading-icon column entirely. */
1128
+ protected getRowIcon(_item: T): string | null {
1129
+ return null;
1130
+ }
1131
+
1132
+ private renderHeader(): TemplateResult {
1133
+ const allIds = this.items.map((i) => this.rowId(i));
1134
+ const allSelected =
1135
+ allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
1136
+ const someSelected = !allSelected && this.selectedIds.size > 0;
1137
+ // Reserve an empty leading-icon column in the header to align
1138
+ // with row icons. We probe a representative row — if any row
1139
+ // returns an icon, every row gets the column (skipped per-row
1140
+ // if its own getRowIcon returns null).
1141
+ const reservesIcon =
1142
+ this.items.length > 0 && this.getRowIcon(this.items[0]) !== null;
1143
+
1144
+ return html`
1145
+ <div class="header">
1146
+ ${this.selectable
1147
+ ? html`
1148
+ <div class="check-cell" @click=${() => this.handleSelectAll()}>
1149
+ <temba-checkbox
1150
+ size="1.1"
1151
+ ?checked=${allSelected}
1152
+ ?partial=${someSelected}
1153
+ ></temba-checkbox>
1154
+ </div>
1155
+ `
1156
+ : null}
1157
+ ${reservesIcon ? html`<div class="icon-cell"></div>` : null}
1158
+ ${this.columns.map((c) => this.renderHeaderCell(c))}
1159
+ </div>
1160
+ `;
1161
+ }
1162
+
1163
+ private renderHeaderCell(column: ContentListColumn): TemplateResult {
1164
+ const style = this.columnStyle(column);
1165
+ const active = this.sort === column.key || this.sort === '-' + column.key;
1166
+ const desc = this.sort === '-' + column.key;
1167
+ // Only sortable columns get a click handler, the `sortable`
1168
+ // class (which paints the cursor + hover state), and the
1169
+ // direction icon. Non-sortable headers render as plain text.
1170
+ if (column.sortable) {
1171
+ return html`
1172
+ <div
1173
+ class="head-cell ${column.align || ''} sortable ${active
1174
+ ? 'active'
1175
+ : ''}"
1176
+ style=${style}
1177
+ @click=${() => this.handleSortClick(column)}
1178
+ >
1179
+ <span>${column.label ?? column.key}</span>
1180
+ <temba-icon
1181
+ name=${active ? (desc ? Icon.sort_down : Icon.sort_up) : Icon.sort}
1182
+ size="0.85"
1183
+ ></temba-icon>
1184
+ </div>
1185
+ `;
1186
+ }
1187
+ return html`
1188
+ <div class="head-cell ${column.align || ''}" style=${style}>
1189
+ <span>${column.label ?? column.key}</span>
1190
+ </div>
1191
+ `;
1192
+ }
1193
+
1194
+ private columnStyle(column: ContentListColumn): string {
1195
+ const parts: string[] = [];
1196
+ if (column.width) {
1197
+ parts.push(`flex: ${column.grow ?? 0} 0 ${column.width}`);
1198
+ } else {
1199
+ parts.push(`flex: ${column.grow ?? 1} 1 0`);
1200
+ }
1201
+ return parts.join('; ');
1202
+ }
1203
+
1204
+ private renderRow(item: T): TemplateResult {
1205
+ const id = this.rowId(item);
1206
+ const selected = this.selectedIds.has(id);
1207
+ const href = this.getRowHref(item);
1208
+ const icon = this.getRowIcon(item);
1209
+ return html`
1210
+ <div
1211
+ class="row ${selected ? 'selected' : ''} ${href ? 'clickable' : ''}"
1212
+ @click=${(e: MouseEvent) => this.handleRowClick(item, e)}
1213
+ >
1214
+ ${this.selectable
1215
+ ? html`
1216
+ <div
1217
+ class="check-cell"
1218
+ @click=${(e: MouseEvent) => {
1219
+ // Cell-level click is the single source of truth
1220
+ // for selection. The inner checkbox has
1221
+ // pointer-events: none so it can't fire a second
1222
+ // toggle on the same click.
1223
+ e.stopPropagation();
1224
+ this.handleRowToggle(item);
1225
+ }}
1226
+ >
1227
+ <temba-checkbox
1228
+ size="1.1"
1229
+ ?checked=${selected}
1230
+ ></temba-checkbox>
1231
+ </div>
1232
+ `
1233
+ : null}
1234
+ ${icon
1235
+ ? html`
1236
+ <div class="icon-cell">
1237
+ <temba-icon name=${icon} size="1"></temba-icon>
1238
+ </div>
1239
+ `
1240
+ : null}
1241
+ ${this.columns.map(
1242
+ (c) => html`
1243
+ <div class="cell ${c.align || ''}" style=${this.columnStyle(c)}>
1244
+ ${this.renderCell(item, c)}
1245
+ </div>
1246
+ `
1247
+ )}
1248
+ </div>
1249
+ `;
1250
+ }
1251
+
1252
+ private renderFooter(): TemplateResult {
1253
+ const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
1254
+ const first = this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
1255
+ const last = Math.min(this.total, this.page * this.pageSize);
1256
+ return html`
1257
+ <div class="footer">
1258
+ <div class="status">
1259
+ ${this.total > 0 ? html`${first}&ndash;${last} of ${this.total}` : ''}
1260
+ </div>
1261
+ <div class="pager">
1262
+ <span
1263
+ class="page-btn"
1264
+ ?disabled=${this.page <= 1}
1265
+ @click=${() => this.handlePage(-1)}
1266
+ aria-label="Previous page"
1267
+ >
1268
+ <temba-icon name=${Icon.arrow_left} size="1"></temba-icon>
1269
+ </span>
1270
+ <span
1271
+ class="page-btn"
1272
+ ?disabled=${this.page >= lastPage}
1273
+ @click=${() => this.handlePage(1)}
1274
+ aria-label="Next page"
1275
+ >
1276
+ <temba-icon name=${Icon.arrow_right} size="1"></temba-icon>
1277
+ </span>
1278
+ </div>
1279
+ </div>
1280
+ `;
1281
+ }
1282
+
1283
+ public render(): TemplateResult {
1284
+ return html`
1285
+ <div class="panel">
1286
+ ${this.renderTitlebar()}
1287
+ <div class="header-rule"></div>
1288
+ ${this.renderHeader()}
1289
+ ${this.loading && this.items.length === 0
1290
+ ? html`<div class="loading">Loading&hellip;</div>`
1291
+ : this.items.length === 0
1292
+ ? html`<div class="empty">${this.emptyMessage}</div>`
1293
+ : this.items.map((i) => this.renderRow(i))}
1294
+ ${this.renderFooter()}
1295
+ </div>
1296
+ `;
1297
+ }
1298
+ }