@nyaruka/temba-components 0.163.0 → 0.165.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +5266 -3031
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/Chat.ts +3 -2
  19. package/src/display/Lightbox.ts +57 -109
  20. package/src/display/ProgressBar.ts +15 -2
  21. package/src/display/Thumbnail.ts +34 -7
  22. package/src/form/Compose.ts +31 -1
  23. package/src/form/ContactSearch.ts +229 -126
  24. package/src/interfaces.ts +131 -2
  25. package/src/layout/Card.ts +336 -0
  26. package/src/layout/CardLayout.ts +425 -0
  27. package/src/layout/CardStack.ts +131 -0
  28. package/src/layout/Dialog.ts +33 -24
  29. package/src/layout/HeaderBar.ts +41 -0
  30. package/src/layout/PageHeader.ts +5 -6
  31. package/src/layout/Resizer.ts +20 -0
  32. package/src/list/BroadcastList.ts +912 -0
  33. package/src/list/CampaignList.ts +144 -0
  34. package/src/list/ContactList.ts +3 -1
  35. package/src/list/ContentList.ts +46 -10
  36. package/src/list/FieldList.ts +1057 -0
  37. package/src/list/FlowList.ts +31 -7
  38. package/src/list/MsgList.ts +18 -12
  39. package/src/list/SortableList.ts +52 -8
  40. package/src/list/TembaList.ts +8 -0
  41. package/src/list/TembaMenu.ts +5 -38
  42. package/src/list/TriggerList.ts +538 -0
  43. package/src/live/CampaignEvents.ts +1108 -0
  44. package/src/live/ContactChat.ts +7 -1
  45. package/src/live/ContactDetails.ts +20 -14
  46. package/src/live/ContactFieldEditor.ts +7 -3
  47. package/src/live/ContactFields.ts +1 -1
  48. package/src/live/ContactNameFetch.ts +5 -2
  49. package/src/live/ContactNotepad.ts +88 -2
  50. package/src/live/ContactStoreElement.ts +15 -3
  51. package/src/live/ContactTimeline.ts +28 -7
  52. package/src/locales/es.ts +4 -0
  53. package/src/locales/fr.ts +4 -0
  54. package/src/locales/pt.ts +4 -0
  55. package/src/utils.ts +19 -0
  56. package/static/svg/index.svg +1 -1
  57. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  58. package/static/svg/work/traced/star-filled.svg +1 -0
  59. package/static/svg/work/used/star-filled.svg +3 -0
  60. package/stress-test.js +3 -3
  61. package/temba-modules.ts +18 -0
  62. package/web-dev-server.config.mjs +54 -0
  63. package/web-test-runner.config.mjs +1 -1
  64. package/xliff/es.xlf +12 -0
  65. package/xliff/fr.xlf +12 -0
  66. package/xliff/pt.xlf +12 -0
@@ -0,0 +1,1057 @@
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { ContactField, CustomEventType } from '../interfaces';
4
+ import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
5
+ import { Icon } from '../Icons';
6
+ import { designTokens } from '../styles/designTokens';
7
+ import { postJSON } from '../utils';
8
+
9
+ const TYPE_NAMES = {
10
+ text: 'Text',
11
+ numeric: 'Number',
12
+ number: 'Number',
13
+ datetime: 'Date & Time',
14
+ state: 'State',
15
+ ward: 'Ward',
16
+ district: 'District'
17
+ };
18
+
19
+ // display name for a field's value type, falling back to the raw type
20
+ // rather than rendering "undefined" for an unmapped type
21
+ const typeName = (field: ContactField): string =>
22
+ TYPE_NAMES[field.value_type] || field.value_type || '';
23
+
24
+ const matches = (field: ContactField, query: string): boolean => {
25
+ if (!query) {
26
+ return true;
27
+ }
28
+ // separate the haystack parts so a query can't match across them
29
+ const search = [field.label, field.key, typeName(field)]
30
+ .join(' ')
31
+ .toLowerCase();
32
+ return search.indexOf(query.toLowerCase()) > -1;
33
+ };
34
+
35
+ // referenced flow / group / campaign in the detail modal's usages
36
+ interface UsageRef {
37
+ uuid?: string;
38
+ name: string;
39
+ url?: string;
40
+ }
41
+
42
+ interface CampaignEventUsage {
43
+ id: number;
44
+ campaign: UsageRef;
45
+ offset_display?: string;
46
+ }
47
+
48
+ interface FieldDetailResponse {
49
+ field: {
50
+ key: string;
51
+ name: string;
52
+ value_type: string;
53
+ featured: boolean;
54
+ agent_access: string;
55
+ };
56
+ usages: {
57
+ flows: UsageRef[];
58
+ groups: UsageRef[];
59
+ campaign_events: CampaignEventUsage[];
60
+ };
61
+ counts: { flows: number; groups: number; campaign_events: number };
62
+ can_edit?: boolean;
63
+ can_delete?: boolean;
64
+ }
65
+
66
+ export class FieldList extends EndpointMonitorElement {
67
+ // title shown in the page header; a `title` slot overrides it
68
+ @property({ type: String, attribute: 'header-title' })
69
+ headerTitle = '';
70
+
71
+ // GET endpoint for the page's content menu (rapidpro's content-menu view);
72
+ // menu clicks fire temba-selection with the item for the host to dispatch
73
+ @property({ type: String, attribute: 'content-menu-endpoint' })
74
+ contentMenuEndpoint = '';
75
+
76
+ // POST endpoint taking {featured: [keys]} — the ordered featured set
77
+ @property({ type: String, attribute: 'priority-endpoint' })
78
+ priorityEndpoint: string;
79
+
80
+ // base path for the field detail JSON; the field key is appended
81
+ @property({ type: String, attribute: 'detail-endpoint' })
82
+ detailEndpoint = '';
83
+
84
+ @property({ type: Object, attribute: false })
85
+ featuredFields: ContactField[];
86
+
87
+ @property({ type: Object, attribute: false })
88
+ otherFieldKeys: string[] = [];
89
+
90
+ @property({ type: String })
91
+ query = '';
92
+
93
+ @state()
94
+ private searchOpen = false;
95
+
96
+ // the field whose detail modal is open
97
+ @state()
98
+ private detailField: ContactField = null;
99
+
100
+ // the detail modal's usages/permissions - null while the fetch is in flight
101
+ @state()
102
+ private detail: FieldDetailResponse = null;
103
+
104
+ static get styles() {
105
+ return css`
106
+ ${designTokens}
107
+
108
+ :host {
109
+ display: flex;
110
+ flex-direction: column;
111
+ font-family: var(--font);
112
+ color: var(--text-1);
113
+ font-size: 13.5px;
114
+ }
115
+
116
+ /* The header is the same flush surface bar the fill-window lists
117
+ render — full width, the lists' 12px inset, no card chrome —
118
+ so this page and the list pages share one header treatment. */
119
+ .header-panel {
120
+ background: var(--surface);
121
+ padding: 0 12px;
122
+ border-bottom: 1px solid var(--border);
123
+ }
124
+
125
+ .header-actions {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 14px;
129
+ color: var(--text-2);
130
+ font-size: 13px;
131
+ }
132
+
133
+ .action {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ gap: 6px;
137
+ cursor: pointer;
138
+ user-select: none;
139
+ height: 26px;
140
+ box-sizing: border-box;
141
+ padding: 0 10px;
142
+ border: 1px solid var(--border-strong);
143
+ border-radius: var(--r-sm);
144
+ background: transparent;
145
+ color: var(--text-2);
146
+ }
147
+ .action:hover {
148
+ background: var(--sunken);
149
+ color: var(--text-1);
150
+ }
151
+ .action temba-icon {
152
+ --icon-color: currentColor;
153
+ }
154
+
155
+ /* Inline search bar — same treatment as the content lists, but
156
+ filtering is live so there's no run/commit affordance. */
157
+ .searchbar {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 6px;
161
+ padding: 6px 12px;
162
+ margin: -6px 0 12px 0;
163
+ background: var(--sunken);
164
+ border-radius: var(--r-sm);
165
+ color: var(--text-3);
166
+ }
167
+ .searchbar input {
168
+ flex: 1 1 auto;
169
+ border: 0;
170
+ background: transparent;
171
+ outline: 0;
172
+ font: inherit;
173
+ color: var(--text-1);
174
+ min-width: 0;
175
+ }
176
+ .searchbar input::placeholder {
177
+ color: var(--text-3);
178
+ }
179
+ .searchbar .search-cancel {
180
+ flex: 0 0 auto;
181
+ margin-left: 5px;
182
+ --icon-color: var(--text-3);
183
+ --icon-color-circle-hover: rgba(15, 22, 36, 0.1);
184
+ }
185
+
186
+ .content {
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 12px;
190
+ padding: 12px;
191
+ }
192
+
193
+ /* Card panel — the same surface treatment as the content lists */
194
+ .panel {
195
+ background: var(--surface);
196
+ border-radius: var(--r);
197
+ overflow: hidden;
198
+ box-shadow: var(--shadow-1);
199
+ }
200
+
201
+ .section-header {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 8px;
205
+ padding: 10px 12px;
206
+ border-bottom: 1px solid var(--border);
207
+ font-weight: var(--w-semibold, 600);
208
+ color: var(--text-2);
209
+ --icon-color: var(--text-3, #7b8593);
210
+ }
211
+
212
+ .rows {
213
+ padding: 6px 0;
214
+ }
215
+
216
+ temba-sortable-list {
217
+ display: block;
218
+ width: 100%;
219
+ }
220
+
221
+ .field {
222
+ display: flex;
223
+ flex-direction: row;
224
+ align-items: center;
225
+ gap: 12px;
226
+ padding: 5px 12px;
227
+ margin: 1px 6px;
228
+ border: 1px solid transparent;
229
+ border-radius: var(--curvature);
230
+ user-select: none;
231
+ cursor: pointer;
232
+ }
233
+
234
+ /* the row outline is the drag affordance, so it only appears when
235
+ the pointer is on the drag handle itself */
236
+ .field:has(.drag-handle:hover) {
237
+ border-color: var(--border, #e4e7ec);
238
+ }
239
+
240
+ /* the drag affordance leads featured rows - always visible, and
241
+ the only place a drag can start from */
242
+ .field .drag-handle {
243
+ flex-shrink: 0;
244
+ cursor: grab;
245
+ --icon-color: var(--text-3, #7b8593);
246
+ }
247
+
248
+ .field .name {
249
+ min-width: 200px;
250
+ width: 200px;
251
+ white-space: nowrap;
252
+ overflow: hidden;
253
+ text-overflow: ellipsis;
254
+ font-weight: var(--w-medium, 500);
255
+ }
256
+
257
+ .field .expression {
258
+ flex-grow: 1;
259
+ font-family: var(--font-mono, 'Roboto Mono', monospace);
260
+ font-size: 0.85em;
261
+ color: var(--text-2);
262
+ white-space: nowrap;
263
+ overflow: hidden;
264
+ text-overflow: ellipsis;
265
+ }
266
+
267
+ .field .type {
268
+ flex-shrink: 0;
269
+ font-size: 0.85em;
270
+ color: var(--text-3, #7b8593);
271
+ }
272
+
273
+ /* the star is the featured toggle - filled and gold when featured,
274
+ hollow and muted when not */
275
+ .star {
276
+ flex-shrink: 0;
277
+ --icon-color: var(--text-3, #7b8593);
278
+ }
279
+
280
+ .star.featured {
281
+ --icon-color: #f5b60d;
282
+ }
283
+
284
+ .empty-note {
285
+ padding: 1em 12px;
286
+ color: var(--text-3, #7b8593);
287
+ font-size: 0.9em;
288
+ }
289
+
290
+ /* the detail modal stands in for a field read page - page-like,
291
+ no colored header bar (same shape as the campaign event modal) */
292
+ .detail {
293
+ display: flex;
294
+ flex-direction: column;
295
+ }
296
+
297
+ .detail-header {
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 12px;
301
+ padding: 1em;
302
+ border-bottom: 1px solid var(--border, #e4e7ec);
303
+ }
304
+
305
+ .detail-title {
306
+ flex: 1;
307
+ min-width: 0;
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 10px;
311
+ font-size: 15.5px;
312
+ font-weight: var(--w-semibold, 600);
313
+ color: var(--text-1);
314
+ }
315
+
316
+ .detail-actions {
317
+ flex-shrink: 0;
318
+ display: flex;
319
+ align-items: center;
320
+ gap: 8px;
321
+ }
322
+
323
+ .detail-actions .star {
324
+ margin-right: 4px;
325
+ }
326
+
327
+ /* the way out - a quiet ✕ at the header's far right, same as the
328
+ campaign event modal */
329
+ .detail-close {
330
+ flex-shrink: 0;
331
+ margin-left: -4px;
332
+ display: inline-flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ width: 28px;
336
+ height: 28px;
337
+ border-radius: var(--r-sm);
338
+ color: var(--text-3, #7b8593);
339
+ cursor: pointer;
340
+ user-select: none;
341
+ }
342
+ .detail-close:hover {
343
+ background: var(--sunken);
344
+ color: var(--text-1);
345
+ }
346
+ .detail-close temba-icon {
347
+ --icon-color: currentColor;
348
+ }
349
+
350
+ /* match the page header's content-menu buttons so the modal's
351
+ actions read like page actions */
352
+ .menu-button {
353
+ display: inline-flex;
354
+ align-items: center;
355
+ gap: 6px;
356
+ box-sizing: border-box;
357
+ height: 26px;
358
+ padding: 0 10px;
359
+ border: 1px solid var(--border-strong, #9ca3af);
360
+ border-radius: var(--r-sm);
361
+ font-size: 12.5px;
362
+ font-weight: var(--w-regular, 400);
363
+ cursor: pointer;
364
+ user-select: none;
365
+ background: var(--surface);
366
+ color: var(--text-1);
367
+ white-space: nowrap;
368
+ }
369
+
370
+ .menu-button:hover {
371
+ background: var(--sunken);
372
+ }
373
+
374
+ .menu-button.destructive {
375
+ color: #dc2626;
376
+ }
377
+
378
+ .menu-button.destructive:hover {
379
+ border-color: #dc2626;
380
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
381
+ }
382
+
383
+ .detail-body {
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 1.25em;
387
+ overflow-y: auto;
388
+ max-height: calc(100vh - 300px);
389
+ padding: 1.25em 1.75em;
390
+ }
391
+
392
+ .detail-meta {
393
+ display: flex;
394
+ align-items: center;
395
+ gap: 12px;
396
+ font-size: 0.9em;
397
+ color: var(--text-3, #7b8593);
398
+ }
399
+
400
+ .detail-meta .expression {
401
+ font-family: var(--font-mono, 'Roboto Mono', monospace);
402
+ color: var(--text-2);
403
+ }
404
+
405
+ .detail-section-title {
406
+ margin-top: 0.25em;
407
+ font-size: 0.75em;
408
+ font-weight: 600;
409
+ text-transform: uppercase;
410
+ letter-spacing: 0.05em;
411
+ color: var(--text-3, #7b8593);
412
+ }
413
+
414
+ .usage-rows {
415
+ display: flex;
416
+ flex-direction: column;
417
+ align-items: flex-start;
418
+ gap: 0.5em;
419
+ margin-top: 0.5em;
420
+ }
421
+
422
+ .usage-row {
423
+ display: flex;
424
+ align-items: center;
425
+ gap: 0.6em;
426
+ }
427
+
428
+ .usage-row .usage-detail {
429
+ font-size: 0.85em;
430
+ color: var(--text-3, #7b8593);
431
+ }
432
+
433
+ .usage-more {
434
+ font-size: 0.85em;
435
+ color: var(--text-3, #7b8593);
436
+ }
437
+
438
+ .no-usages {
439
+ color: var(--text-3, #7b8593);
440
+ font-size: 0.9em;
441
+ }
442
+ `;
443
+ }
444
+
445
+ connectedCallback(): void {
446
+ super.connectedCallback();
447
+ if (this.store?.fieldsEndpoint) {
448
+ this.url = this.store.fieldsEndpoint;
449
+ }
450
+ }
451
+
452
+ public willUpdate(changed: PropertyValues): void {
453
+ super.willUpdate(changed);
454
+ if (changed.has('data') || changed.has('query')) {
455
+ this.filterFields();
456
+ }
457
+ }
458
+
459
+ protected updated(changed: PropertyValues): void {
460
+ super.updated(changed);
461
+ if (changed.has('searchOpen') && this.searchOpen) {
462
+ const input = this.shadowRoot.querySelector(
463
+ '.searchbar input'
464
+ ) as HTMLInputElement;
465
+ input?.focus();
466
+ }
467
+ }
468
+
469
+ private filterFields() {
470
+ const filteredKeys = this.store.getFieldKeys().filter((key) => {
471
+ const field = this.store.getContactField(key);
472
+ if (field.featured) {
473
+ return false;
474
+ }
475
+ return matches(field, this.query);
476
+ });
477
+
478
+ // sort by the label instead of the key
479
+ filteredKeys.sort((a, b) => {
480
+ return this.store
481
+ .getContactField(a)
482
+ .label.localeCompare(this.store.getContactField(b).label);
483
+ });
484
+
485
+ const featured: ContactField[] = [];
486
+ this.store.getFeaturedFields().forEach((field) => {
487
+ if (matches(field, this.query)) {
488
+ featured.push(field);
489
+ }
490
+ });
491
+
492
+ this.otherFieldKeys = filteredKeys;
493
+ this.featuredFields = featured;
494
+ }
495
+
496
+ // the featured set + order is saved as one atomic list; the server
497
+ // derives show_in_table and descending priorities from it
498
+ private savePriorities() {
499
+ postJSON(this.priorityEndpoint, {
500
+ featured: this.featuredFields.map((field) => field.key)
501
+ })
502
+ .catch((error) => {
503
+ console.warn('Failed to save featured fields', error);
504
+ })
505
+ .finally(() => {
506
+ // reconcile the optimistic featured/order state with server
507
+ // truth on success and failure alike - the refetch recomputes
508
+ // both lists via filterFields
509
+ this.store.refreshFields();
510
+ });
511
+ }
512
+
513
+ private isFeatured(key: string): boolean {
514
+ return (this.featuredFields || []).some((field) => field.key === key);
515
+ }
516
+
517
+ // optimistically append a field to the featured list - the store
518
+ // refresh after the save makes it durable; ordering is a drag affair
519
+ private featureField(key: string) {
520
+ const field = this.store.getContactField(key);
521
+ if (!field || this.isFeatured(key)) {
522
+ return;
523
+ }
524
+ this.featuredFields = [...this.featuredFields, field];
525
+ this.otherFieldKeys = this.otherFieldKeys.filter((k) => k !== key);
526
+ this.savePriorities();
527
+ }
528
+
529
+ private unfeatureField(key: string) {
530
+ if (!this.isFeatured(key)) {
531
+ return;
532
+ }
533
+ this.featuredFields = this.featuredFields.filter(
534
+ (field) => field.key !== key
535
+ );
536
+ const keys = [...this.otherFieldKeys, key];
537
+ keys.sort((a, b) =>
538
+ this.store
539
+ .getContactField(a)
540
+ .label.localeCompare(this.store.getContactField(b).label)
541
+ );
542
+ this.otherFieldKeys = keys;
543
+ this.savePriorities();
544
+ }
545
+
546
+ private toggleFeatured(key: string) {
547
+ if (this.isFeatured(key)) {
548
+ this.unfeatureField(key);
549
+ } else {
550
+ this.featureField(key);
551
+ }
552
+ }
553
+
554
+ // dress the drag ghost as a lifted copy of the row: the row itself is
555
+ // transparent over the card surface and offset by its margin, so give
556
+ // the ghost its own surface and zero the margin (border-box keeps the
557
+ // inlined padding from widening it past the measured rect)
558
+ private prepareGhost = (ghost: HTMLElement) => {
559
+ ghost.style.margin = '0';
560
+ ghost.style.boxSizing = 'border-box';
561
+ ghost.style.background = 'var(--surface, #fff)';
562
+ ghost.style.border = '1px solid var(--border, #e4e7ec)';
563
+ ghost.style.borderRadius = 'var(--curvature, 6px)';
564
+ ghost.style.boxShadow = 'var(--shadow-1, 0 2px 6px rgba(0, 0, 0, 0.12))';
565
+ };
566
+
567
+ private handleFeaturedOrderChanged(event: CustomEvent) {
568
+ const [fromIdx, toIdx] = event.detail.swap;
569
+ const featured = [...this.featuredFields];
570
+ const temp = featured[fromIdx];
571
+ featured.splice(fromIdx, 1);
572
+ featured.splice(toIdx, 0, temp);
573
+ this.featuredFields = featured;
574
+ this.savePriorities();
575
+ }
576
+
577
+ // the row star toggles featured without opening the row's detail modal
578
+ private handleStarClicked(event: MouseEvent, field: ContactField) {
579
+ event.stopPropagation();
580
+ this.toggleFeatured(field.key);
581
+ }
582
+
583
+ // keyboard activation of the star must not also fire the row's keydown
584
+ // (which would open the detail modal), so swallow the event here
585
+ private handleStarKeydown(event: KeyboardEvent, field: ContactField) {
586
+ if (event.key === 'Enter' || event.key === ' ') {
587
+ event.stopPropagation();
588
+ }
589
+ this.handleActivationKey(event, () => this.toggleFeatured(field.key));
590
+ }
591
+
592
+ private handleSearchInput(event: InputEvent) {
593
+ // keep the visible text verbatim - matches() lowercases the query
594
+ // itself, so no trim/lowercase here that would mutate what's typed
595
+ this.query = (event.target as HTMLInputElement).value || '';
596
+ }
597
+
598
+ private toggleSearch() {
599
+ this.searchOpen = !this.searchOpen;
600
+ if (!this.searchOpen) {
601
+ this.query = '';
602
+ }
603
+ }
604
+
605
+ // in place of a field read page, clicking a row opens its detail
606
+ // modal, which also lazily loads the field's usages and permissions
607
+ private handleFieldClicked(field: ContactField) {
608
+ this.detailField = field;
609
+ this.loadDetail(field);
610
+ }
611
+
612
+ private async loadDetail(field: ContactField): Promise<void> {
613
+ this.detail = null;
614
+ if (!this.detailEndpoint) {
615
+ return;
616
+ }
617
+ // tolerate a host passing the base path without a trailing slash
618
+ const base = this.detailEndpoint.endsWith('/')
619
+ ? this.detailEndpoint
620
+ : `${this.detailEndpoint}/`;
621
+ try {
622
+ const response = await this.store.getUrl(
623
+ `${base}${encodeURIComponent(field.key)}/`,
624
+ { force: true }
625
+ );
626
+ if (this.detailField !== field) {
627
+ return;
628
+ }
629
+ this.detail = response.json;
630
+ } catch {
631
+ // leave the modal usable with just the store's data
632
+ }
633
+ }
634
+
635
+ // Everything the host must dispatch flows through temba-selection with
636
+ // one of three payload shapes, matching the campaign events contract:
637
+ // a content-menu item ({item, event, ...}) from the embedded page
638
+ // header, a row action ({key, action: 'update'|'delete'}) to open the
639
+ // edit/delete modals, or a usage reference ({uuid, name, url}) to
640
+ // navigate to.
641
+
642
+ // edit / delete close the detail modal before the host opens the edit
643
+ // or delete-confirm modal in its place - modals never stack
644
+ private handleEditClicked() {
645
+ const field = this.detailField;
646
+ this.detailField = null;
647
+ this.fireCustomEvent(CustomEventType.Selection, {
648
+ key: field.key,
649
+ action: 'update'
650
+ });
651
+ }
652
+
653
+ private handleDeleteClicked() {
654
+ const field = this.detailField;
655
+ this.detailField = null;
656
+ this.fireCustomEvent(CustomEventType.Selection, {
657
+ key: field.key,
658
+ action: 'delete'
659
+ });
660
+ }
661
+
662
+ private handleToggleFeatured() {
663
+ this.toggleFeatured(this.detailField.key);
664
+ }
665
+
666
+ // navigating to a usage from the detail modal closes it first
667
+ private handleUsageClicked(usage: UsageRef) {
668
+ this.detailField = null;
669
+ this.fireCustomEvent(CustomEventType.Selection, usage);
670
+ }
671
+
672
+ private handleDetailClosed = () => {
673
+ this.detailField = null;
674
+ };
675
+
676
+ // activate a non-button row on Enter/Space (matches native button behavior)
677
+ private handleActivationKey(e: KeyboardEvent, action: () => void) {
678
+ if (e.key === 'Enter' || e.key === ' ') {
679
+ e.preventDefault();
680
+ action();
681
+ }
682
+ }
683
+
684
+ private renderHeader(): TemplateResult {
685
+ return html`
686
+ <div class="header-panel">
687
+ <temba-page-header content-menu-endpoint=${this.contentMenuEndpoint}>
688
+ <slot name="title" slot="title">${this.headerTitle}</slot>
689
+ <div slot="actions" class="header-actions">
690
+ ${!this.searchOpen
691
+ ? html`
692
+ <span class="action" @click=${() => this.toggleSearch()}>
693
+ <temba-icon name=${Icon.search} size="0.95"></temba-icon>
694
+ Search
695
+ </span>
696
+ `
697
+ : null}
698
+ </div>
699
+ </temba-page-header>
700
+ ${this.searchOpen
701
+ ? html`
702
+ <div class="searchbar">
703
+ <input
704
+ type="text"
705
+ placeholder="Search fields"
706
+ .value=${this.query}
707
+ @input=${this.handleSearchInput}
708
+ @keydown=${(e: KeyboardEvent) => {
709
+ if (e.key === 'Escape') {
710
+ this.toggleSearch();
711
+ }
712
+ }}
713
+ />
714
+ <temba-icon
715
+ class="search-cancel"
716
+ name=${Icon.close}
717
+ size="1.1"
718
+ clickable
719
+ title="Cancel search"
720
+ aria-label="Cancel search"
721
+ @click=${() => this.toggleSearch()}
722
+ ></temba-icon>
723
+ </div>
724
+ `
725
+ : null}
726
+ </div>
727
+ `;
728
+ }
729
+
730
+ private renderStar(field: ContactField): TemplateResult {
731
+ const featured = this.isFeatured(field.key);
732
+ return html`
733
+ <temba-icon
734
+ class="star ${featured ? 'featured' : ''}"
735
+ name=${featured ? Icon.featured_filled : Icon.featured}
736
+ size="1.1"
737
+ clickable
738
+ role="button"
739
+ tabindex="0"
740
+ title=${featured ? 'Featured' : 'Not featured'}
741
+ aria-label=${featured
742
+ ? `Unfeature ${field.label}`
743
+ : `Feature ${field.label}`}
744
+ @click=${(e: MouseEvent) => this.handleStarClicked(e, field)}
745
+ @keydown=${(e: KeyboardEvent) => this.handleStarKeydown(e, field)}
746
+ ></temba-icon>
747
+ `;
748
+ }
749
+
750
+ // sortable rows lead with a drag handle and are draggable from it;
751
+ // every row trails with its featured-toggle star
752
+ private renderField(field: ContactField, sortable = false): TemplateResult {
753
+ return html`
754
+ <div
755
+ class="field ${sortable ? 'sortable' : ''}"
756
+ id="${field.key}"
757
+ tabindex="0"
758
+ @click=${() => this.handleFieldClicked(field)}
759
+ @keydown=${(e: KeyboardEvent) =>
760
+ this.handleActivationKey(e, () => this.handleFieldClicked(field))}
761
+ >
762
+ ${sortable
763
+ ? html`<temba-icon
764
+ class="drag-handle"
765
+ name=${Icon.drag}
766
+ ></temba-icon>`
767
+ : null}
768
+ <div class="name">${field.label}</div>
769
+ <div class="expression">@fields.${field.key}</div>
770
+ <div class="type">${typeName(field)}</div>
771
+ ${this.renderStar(field)}
772
+ </div>
773
+ `;
774
+ }
775
+
776
+ private renderFeaturedPanel(): TemplateResult {
777
+ let body: TemplateResult;
778
+ if (this.featuredFields.length === 0) {
779
+ body = html`
780
+ <div class="empty-note">
781
+ ${this.query
782
+ ? 'No matches'
783
+ : 'Star a field to feature it on contact pages'}
784
+ </div>
785
+ `;
786
+ } else if (this.query) {
787
+ body = html`
788
+ <div class="rows">
789
+ ${this.featuredFields.map((field) => this.renderField(field))}
790
+ </div>
791
+ `;
792
+ } else {
793
+ body = html`
794
+ <div class="rows">
795
+ <temba-sortable-list
796
+ id="featured-list"
797
+ dragHandle="drag-handle"
798
+ .prepareGhost=${this.prepareGhost}
799
+ @temba-order-changed=${this.handleFeaturedOrderChanged}
800
+ >
801
+ ${this.featuredFields.map((field) => this.renderField(field, true))}
802
+ </temba-sortable-list>
803
+ </div>
804
+ `;
805
+ }
806
+
807
+ return html`
808
+ <div class="panel" id="featured-panel">
809
+ <div class="section-header">
810
+ <temba-icon name=${Icon.featured_filled}></temba-icon>
811
+ <div>Featured</div>
812
+ </div>
813
+ ${body}
814
+ </div>
815
+ `;
816
+ }
817
+
818
+ private renderOtherPanel(): TemplateResult {
819
+ return html`
820
+ <div class="panel" id="other-panel">
821
+ <div class="section-header">
822
+ <temba-icon name=${Icon.fields}></temba-icon>
823
+ <div>Other Fields</div>
824
+ </div>
825
+ ${this.otherFieldKeys.length === 0
826
+ ? html`<div class="empty-note">
827
+ ${this.query ? 'No matches' : 'No fields'}
828
+ </div>`
829
+ : html`<div class="rows">
830
+ ${this.otherFieldKeys.map((key) =>
831
+ this.renderField(this.store.getContactField(key))
832
+ )}
833
+ </div>`}
834
+ </div>
835
+ `;
836
+ }
837
+
838
+ private renderUsageSection(
839
+ title: string,
840
+ type: string,
841
+ items: UsageRef[],
842
+ total: number
843
+ ): TemplateResult | null {
844
+ if (!items || items.length === 0) {
845
+ return null;
846
+ }
847
+ return html`
848
+ <div>
849
+ <div class="detail-section-title">${title}</div>
850
+ <div class="usage-rows">
851
+ ${items.map(
852
+ (usage) => html`
853
+ <temba-label
854
+ type=${type}
855
+ clickable
856
+ @click=${() => this.handleUsageClicked(usage)}
857
+ >${usage.name}</temba-label
858
+ >
859
+ `
860
+ )}
861
+ ${total > items.length
862
+ ? html`<div class="usage-more">
863
+ and ${total - items.length} more
864
+ </div>`
865
+ : null}
866
+ </div>
867
+ </div>
868
+ `;
869
+ }
870
+
871
+ private renderCampaignEventUsages(): TemplateResult | null {
872
+ const events = this.detail?.usages?.campaign_events || [];
873
+ if (events.length === 0) {
874
+ return null;
875
+ }
876
+ const total = this.detail.counts?.campaign_events || events.length;
877
+ return html`
878
+ <div>
879
+ <div class="detail-section-title">Campaign Events</div>
880
+ <div class="usage-rows">
881
+ ${events.map(
882
+ (event) => html`
883
+ <div class="usage-row">
884
+ <temba-label
885
+ type="campaign"
886
+ clickable
887
+ @click=${() => this.handleUsageClicked(event.campaign)}
888
+ >${event.campaign?.name}</temba-label
889
+ >
890
+ ${event.offset_display
891
+ ? html`<div class="usage-detail">
892
+ ${event.offset_display}
893
+ </div>`
894
+ : null}
895
+ </div>
896
+ `
897
+ )}
898
+ ${total > events.length
899
+ ? html`<div class="usage-more">
900
+ and ${total - events.length} more
901
+ </div>`
902
+ : null}
903
+ </div>
904
+ </div>
905
+ `;
906
+ }
907
+
908
+ private renderUsages(): TemplateResult {
909
+ if (!this.detail) {
910
+ return html`<temba-loading units="3" size="8"></temba-loading>`;
911
+ }
912
+
913
+ const usages = this.detail.usages || {
914
+ flows: [],
915
+ groups: [],
916
+ campaign_events: []
917
+ };
918
+ const counts = this.detail.counts || {
919
+ flows: 0,
920
+ groups: 0,
921
+ campaign_events: 0
922
+ };
923
+
924
+ const hasUsages =
925
+ (usages.flows || []).length +
926
+ (usages.groups || []).length +
927
+ (usages.campaign_events || []).length >
928
+ 0;
929
+
930
+ if (!hasUsages) {
931
+ return html`<div class="no-usages">
932
+ Not used by any flows, groups or campaigns.
933
+ </div>`;
934
+ }
935
+
936
+ return html`
937
+ ${this.renderUsageSection('Flows', 'flow', usages.flows, counts.flows)}
938
+ ${this.renderUsageSection(
939
+ 'Groups',
940
+ 'group',
941
+ usages.groups,
942
+ counts.groups
943
+ )}
944
+ ${this.renderCampaignEventUsages()}
945
+ `;
946
+ }
947
+
948
+ // modal detail view for a field, standing in for a field read page -
949
+ // the feature toggle, edit and delete actions, and the field's usages
950
+ private renderDetail(): TemplateResult {
951
+ const field = this.detailField;
952
+ const featured = field ? this.isFeatured(field.key) : false;
953
+
954
+ return html`
955
+ <temba-dialog
956
+ size="xlarge"
957
+ variant="flat"
958
+ primaryButtonName=""
959
+ cancelButtonName=""
960
+ hideOnClick
961
+ ?open=${!!field}
962
+ @temba-dialog-hidden=${this.handleDetailClosed}
963
+ @keyup=${(e: KeyboardEvent) => {
964
+ if (e.key === 'Escape') {
965
+ this.handleDetailClosed();
966
+ }
967
+ }}
968
+ >
969
+ ${field
970
+ ? html`
971
+ <div class="detail">
972
+ <div class="detail-header">
973
+ <div class="detail-title">${field.label}</div>
974
+ ${this.detail
975
+ ? html`<div class="detail-actions">
976
+ ${this.detail.can_edit
977
+ ? html`<temba-icon
978
+ class="star featured-toggle ${featured
979
+ ? 'featured'
980
+ : ''}"
981
+ name=${featured
982
+ ? Icon.featured_filled
983
+ : Icon.featured}
984
+ size="1.3"
985
+ clickable
986
+ role="button"
987
+ tabindex="0"
988
+ title=${featured ? 'Featured' : 'Not featured'}
989
+ aria-label=${featured
990
+ ? `Unfeature ${field.label}`
991
+ : `Feature ${field.label}`}
992
+ @click=${this.handleToggleFeatured}
993
+ @keydown=${(e: KeyboardEvent) =>
994
+ this.handleActivationKey(e, () =>
995
+ this.handleToggleFeatured()
996
+ )}
997
+ ></temba-icon>`
998
+ : null}
999
+ ${this.detail.can_edit
1000
+ ? html`<button
1001
+ class="menu-button"
1002
+ @click=${this.handleEditClicked}
1003
+ >
1004
+ Edit
1005
+ </button>`
1006
+ : null}
1007
+ ${this.detail.can_delete
1008
+ ? html`<button
1009
+ class="menu-button destructive"
1010
+ @click=${this.handleDeleteClicked}
1011
+ >
1012
+ Delete
1013
+ </button>`
1014
+ : null}
1015
+ </div>`
1016
+ : null}
1017
+ <div
1018
+ class="detail-close"
1019
+ role="button"
1020
+ tabindex="0"
1021
+ aria-label="Close"
1022
+ @click=${this.handleDetailClosed}
1023
+ @keydown=${(e: KeyboardEvent) =>
1024
+ this.handleActivationKey(e, this.handleDetailClosed)}
1025
+ >
1026
+ <temba-icon name=${Icon.close} size="1.4"></temba-icon>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ <div class="detail-body">
1031
+ <div class="detail-meta">
1032
+ <div class="expression">@fields.${field.key}</div>
1033
+ <div>${typeName(field)}</div>
1034
+ </div>
1035
+ ${this.renderUsages()}
1036
+ </div>
1037
+ </div>
1038
+ `
1039
+ : null}
1040
+ </temba-dialog>
1041
+ `;
1042
+ }
1043
+
1044
+ public render(): TemplateResult {
1045
+ if (!this.featuredFields) {
1046
+ return null;
1047
+ }
1048
+
1049
+ return html`
1050
+ ${this.renderHeader()}
1051
+ <div class="content">
1052
+ ${this.renderFeaturedPanel()} ${this.renderOtherPanel()}
1053
+ </div>
1054
+ ${this.renderDetail()}
1055
+ `;
1056
+ }
1057
+ }