@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.
- package/CHANGELOG.md +23 -0
- package/dist/temba-components.js +1032 -204
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Dropdown.ts +17 -5
- package/src/flow/nodes/split_by_resthook.ts +3 -3
- package/src/interfaces.ts +4 -1
- package/src/layout/TabPane.ts +3 -1
- package/src/list/ContactList.ts +225 -0
- package/src/list/ContentList.ts +1298 -0
- package/src/list/FlowList.ts +251 -0
- package/src/list/MsgList.ts +144 -0
- package/static/api/flow-labels.json +31 -0
- package/temba-modules.ts +8 -0
- package/web-dev-server.config.mjs +156 -0
|
@@ -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…</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}–${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…</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
|
+
}
|