@nyaruka/temba-components 0.169.0 → 0.171.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.
@@ -1,7 +1,13 @@
1
1
  import { TemplateResult, html, css, PropertyValues, nothing } from 'lit';
2
2
  import { FieldElement } from './FieldElement';
3
3
  import { property } from 'lit/decorators.js';
4
- import { Attachment, CustomEventType, Language, Shortcut } from '../interfaces';
4
+ import {
5
+ Attachment,
6
+ CustomEventType,
7
+ Language,
8
+ QuickReply,
9
+ Shortcut
10
+ } from '../interfaces';
5
11
  import { Icon } from '../Icons';
6
12
  import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
7
13
  import { Select } from './select/Select';
@@ -12,7 +18,7 @@ import { setCaretOffset } from '../excellent/caret-utils';
12
18
  export interface ComposeValue {
13
19
  text: string;
14
20
  attachments: { uuid: string }[];
15
- quick_replies: string[];
21
+ quick_replies: (string | QuickReply)[];
16
22
  optin: string;
17
23
  template: string;
18
24
  variables: string[];
@@ -236,7 +242,7 @@ export class Compose extends FieldElement {
236
242
  [lang: string]: {
237
243
  text: string;
238
244
  attachments: Attachment[];
239
- quick_replies: string[];
245
+ quick_replies: (string | QuickReply)[];
240
246
  optin?: { name: string; uuid: string };
241
247
  template?: string;
242
248
  variables?: string[];
@@ -357,11 +363,12 @@ export class Compose extends FieldElement {
357
363
  this.currentText = langValue.text || '';
358
364
  this.initialText = langValue.text || '';
359
365
  this.currentAttachments = langValue.attachments || [];
360
- this.currentQuickReplies = (langValue.quick_replies || []).map(
361
- (value) => {
362
- return { name: value, value };
363
- }
364
- );
366
+ this.currentQuickReplies = (langValue.quick_replies || [])
367
+ .map((reply) =>
368
+ typeof reply === 'string' ? reply : (reply.text ?? null)
369
+ )
370
+ .filter((reply): reply is string => reply !== null)
371
+ .map((value) => ({ name: value, value }));
365
372
  this.currentOptin = langValue['optin'] ? [langValue['optin']] : [];
366
373
  }
367
374
 
@@ -410,25 +417,14 @@ export class Compose extends FieldElement {
410
417
  // own keydown inserts a newline (which would flash before the send
411
418
  // clears it); bubble keeps Enter-to-send working from anywhere else
412
419
  // in the compose after inner widgets have had their shot at it
413
- this.addEventListener(
414
- 'keydown',
415
- this.handleHostKeyDown as EventListener,
416
- true
417
- );
418
- this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
420
+ this.addEventListener('keydown', this.handleHostKeyDownCapture, true);
421
+ this.addEventListener('keydown', this.handleHostKeyDownBubble);
419
422
  }
420
423
 
421
424
  disconnectedCallback() {
422
425
  super.disconnectedCallback();
423
- this.removeEventListener(
424
- 'keydown',
425
- this.handleHostKeyDown as EventListener,
426
- true
427
- );
428
- this.removeEventListener(
429
- 'keydown',
430
- this.handleHostKeyDown as EventListener
431
- );
426
+ this.removeEventListener('keydown', this.handleHostKeyDownCapture, true);
427
+ this.removeEventListener('keydown', this.handleHostKeyDownBubble);
432
428
  }
433
429
 
434
430
  private handleShortcutIconClick() {
@@ -454,7 +450,18 @@ export class Compose extends FieldElement {
454
450
  });
455
451
  }
456
452
 
457
- private handleHostKeyDown = (evt: KeyboardEvent) => {
453
+ // events from inside our shadow tree are retargeted to the host, so
454
+ // both registrations see eventPhase as AT_TARGET — the phase can't
455
+ // tell us which registration is firing, so each binds it explicitly
456
+ private handleHostKeyDownCapture = (evt: KeyboardEvent) => {
457
+ this.handleHostKeyDown(evt, true);
458
+ };
459
+
460
+ private handleHostKeyDownBubble = (evt: KeyboardEvent) => {
461
+ this.handleHostKeyDown(evt, false);
462
+ };
463
+
464
+ private handleHostKeyDown = (evt: KeyboardEvent, capture: boolean) => {
458
465
  if (evt.key === 'Escape' && this.showShortcuts) {
459
466
  evt.preventDefault();
460
467
  evt.stopPropagation();
@@ -474,10 +481,12 @@ export class Compose extends FieldElement {
474
481
  // during capture only editor presses are claimed — anything else
475
482
  // (quick replies, attachments) waits for the bubble so the widget
476
483
  // it targets keeps first claim on the event
477
- if (
478
- evt.eventPhase === Event.CAPTURING_PHASE &&
479
- !(editor && evt.composedPath().includes(editor))
480
- ) {
484
+ if (capture && !(editor && evt.composedPath().includes(editor))) {
485
+ return;
486
+ }
487
+ // by the bubble an inner widget may have claimed the Enter for
488
+ // itself (e.g. the quick reply select adding a tag) — not a send
489
+ if (!capture && evt.defaultPrevented) {
481
490
  return;
482
491
  }
483
492
  if (editor) {
@@ -4,107 +4,157 @@ import { DateTime } from 'luxon';
4
4
  import { html, css, PropertyValues } from 'lit';
5
5
  import { DatePicker } from './DatePicker';
6
6
  import { CustomEventType } from '../interfaces';
7
+ import { Icon } from '../Icons';
8
+ import { designTokens } from '../styles/designTokens';
7
9
 
8
10
  export class RangePicker extends RapidElement {
9
11
  static styles = css`
12
+ ${designTokens}
13
+
14
+ :host {
15
+ display: inline-block;
16
+ font-family: var(--font);
17
+ }
18
+
10
19
  .range-container {
11
20
  display: flex;
12
- gap: 0.5em;
21
+ gap: 8px;
13
22
  align-items: center;
23
+ font-size: 13.5px;
24
+ color: var(--text-1);
14
25
  }
26
+
15
27
  .date-display {
16
28
  cursor: pointer;
17
- padding: 0.2em 0.5em;
18
- margin: 0.6em 0;
19
- border-radius: var(--curvature-widget);
20
- border: 1px solid transparent;
21
- transition: border 0.2s;
29
+ padding: 3px 6px;
30
+ border-radius: var(--r-sm);
31
+ transition:
32
+ background 120ms,
33
+ color 120ms;
22
34
  }
23
35
 
24
36
  .date-display:hover {
25
- border: 1px solid var(--color-widget-border);
26
37
  background: var(--sunken);
27
38
  }
28
39
 
29
- input[type='date'] {
30
- font-size: 1em;
31
- padding: 0.2em 0.5em;
32
- border-radius: var(--curvature-widget);
33
- border: 1px solid var(--color-widget-border);
40
+ .range-separator {
41
+ color: var(--text-3);
34
42
  }
35
43
 
36
44
  .navigation-container {
37
45
  display: flex;
38
46
  align-items: center;
39
- gap: 0.25em;
47
+ gap: 4px;
40
48
  }
41
49
 
50
+ /* Chevron-only step buttons, same chrome as the list pager: bare
51
+ glyph at rest, sunken wash on hover. No border of their own, so
52
+ they don't compete with the period track between them. */
42
53
  .nav-arrow {
43
- background: var(--sunken);
44
- border: 1px solid var(--color-widget-border);
45
-
46
- border-radius: var(--curvature-widget);
47
- padding: 0em 0em;
48
- cursor: pointer;
49
- font-size: 0.6em;
50
- display: flex;
54
+ display: inline-flex;
51
55
  align-items: center;
52
56
  justify-content: center;
53
- width: 23px;
54
- height: 23px;
57
+ width: 24px;
58
+ height: 24px;
59
+ padding: 0;
60
+ border: 0;
61
+ background: transparent;
62
+ border-radius: var(--r-sm);
63
+ color: var(--text-3);
64
+ cursor: pointer;
55
65
  transition:
56
- background 0.2s,
57
- border 0.2s,
58
- opacity 0.2s;
66
+ background 120ms,
67
+ color 120ms,
68
+ opacity 120ms;
69
+ }
70
+
71
+ .nav-arrow temba-icon {
72
+ --icon-color: currentColor;
59
73
  }
60
74
 
61
75
  .nav-arrow:hover:not(:disabled) {
62
- background: var(--accent-50);
63
- border-color: var(--accent);
76
+ background: var(--sunken);
77
+ color: var(--text-1);
64
78
  }
65
79
 
66
80
  .nav-arrow:disabled {
67
- opacity: 0.5;
68
- cursor: not-allowed;
69
- background: var(--sunken);
81
+ opacity: 0.35;
82
+ cursor: default;
70
83
  }
71
84
 
72
85
  .nav-arrow.hidden {
73
86
  visibility: hidden;
74
87
  }
75
88
 
89
+ /* Segmented control — one bordered sunken track holds every
90
+ period and the selected one lifts out as a surface pill. The
91
+ buttons carry no borders themselves, so there are no
92
+ overlapping 1px edges to clip the selection's outline (the old
93
+ -1px margin stacking left the selected button's left border
94
+ painted over by its neighbor). */
76
95
  .button-group {
77
- display: flex;
78
- margin-left: 0em;
96
+ display: inline-flex;
97
+ align-items: center;
98
+ gap: 2px;
99
+ padding: 2px;
100
+ background: var(--sunken);
101
+ border: 1px solid var(--border);
102
+ border-radius: var(--r-sm);
79
103
  }
104
+
80
105
  .range-btn {
81
- background: var(--sunken);
82
- border: 1px solid var(--color-widget-border);
83
- border-radius: 0px;
84
- margin-left: -1px;
85
- padding: 0.2em 0.8em;
106
+ height: 22px;
107
+ padding: 0 10px;
108
+ border: 0;
109
+ background: transparent;
110
+ border-radius: var(--r-xs);
111
+ font-family: inherit;
112
+ font-size: 12.5px;
113
+ font-weight: var(--w-medium);
114
+ line-height: 1;
115
+ color: var(--text-2);
86
116
  cursor: pointer;
87
- font-size: 0.95em;
88
117
  transition:
89
- background 0.2s,
90
- border 0.2s;
118
+ background 120ms,
119
+ color 120ms,
120
+ box-shadow 120ms;
91
121
  }
92
122
 
93
- .button-group .range-btn:first-child {
94
- border-radius: var(--curvature-widget) 0 0 var(--curvature-widget);
123
+ /* The hover wash sits on the sunken track, so --sunken itself
124
+ would be invisible here. Derived from --text-1 rather than a raw
125
+ rgba literal so it follows the palette (the tokens file mixes
126
+ against transparent the same way for --focus-halo). */
127
+ .range-btn:hover:not(.selected) {
128
+ background: color-mix(in srgb, var(--text-1) 6%, transparent);
129
+ color: var(--text-1);
95
130
  }
96
131
 
97
- .button-group .range-btn:last-child {
98
- border-radius: 0 var(--curvature-widget) var(--curvature-widget) 0;
132
+ .range-btn.selected {
133
+ background: var(--surface);
134
+ color: var(--accent-700);
135
+ box-shadow: var(--shadow-1);
136
+ cursor: default;
99
137
  }
100
138
 
101
- .range-btn.selected,
102
- .range-btn:active {
103
- background: var(--accent-50);
104
- border-color: var(--accent);
139
+ .range-btn:focus-visible,
140
+ .nav-arrow:focus-visible {
141
+ outline: none;
142
+ box-shadow: var(--focus-halo);
105
143
  }
106
144
  `;
107
145
 
146
+ // the periods in the segmented control, in display order
147
+ private static RANGES: {
148
+ type: 'W' | 'M' | 'Y' | 'ALL';
149
+ label: string;
150
+ title: string;
151
+ }[] = [
152
+ { type: 'W', label: 'W', title: 'Last week' },
153
+ { type: 'M', label: 'M', title: 'Last month' },
154
+ { type: 'Y', label: 'Y', title: 'Last year' },
155
+ { type: 'ALL', label: 'All', title: 'All time' }
156
+ ];
157
+
108
158
  @property({ type: String, attribute: 'start' })
109
159
  startDate = '';
110
160
 
@@ -465,6 +515,15 @@ export class RangePicker extends RapidElement {
465
515
  } ${amount} ${unit}`;
466
516
  }
467
517
 
518
+ private getNavigationTitle(direction: 'previous' | 'next'): string {
519
+ const prefix = direction === 'previous' ? 'Previous' : 'Next';
520
+ if (this.selectedRange === 'W') return `${prefix} week`;
521
+ if (this.selectedRange === 'M') return `${prefix} month`;
522
+ if (this.selectedRange === 'Y') return `${prefix} year`;
523
+ if (this.selectedRange === '') return this.getNavigationLabel(direction);
524
+ return `${prefix} period`;
525
+ }
526
+
468
527
  willUpdate(changed: PropertyValues) {
469
528
  super.willUpdate(changed);
470
529
 
@@ -543,7 +602,7 @@ export class RangePicker extends RapidElement {
543
602
  >${this.formatDateForDisplay(this.startDate) ||
544
603
  'Start date'}</span
545
604
  >`}
546
- <span> - </span>
605
+ <span class="range-separator">–</span>
547
606
  ${this.editingEnd
548
607
  ? html`<temba-datepicker
549
608
  .value=${this.endDate}
@@ -566,61 +625,34 @@ export class RangePicker extends RapidElement {
566
625
  class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
567
626
  ?disabled=${!this.canNavigatePrevious()}
568
627
  @click=${this.navigatePrevious}
569
- title="Previous ${this.selectedRange === 'W'
570
- ? 'week'
571
- : this.selectedRange === 'M'
572
- ? 'month'
573
- : this.selectedRange === 'Y'
574
- ? 'year'
575
- : this.selectedRange === ''
576
- ? this.getNavigationLabel('previous')
577
- : 'period'}"
628
+ title=${this.getNavigationTitle('previous')}
629
+ aria-label=${this.getNavigationTitle('previous')}
578
630
  >
579
-
631
+ <temba-icon name=${Icon.arrow_left}></temba-icon>
580
632
  </button>
581
- <div class="button-group">
582
- <button
583
- class="range-btn ${this.selectedRange === 'W' ? 'selected' : ''}"
584
- @click=${() => this.setRange('W')}
585
- >
586
- W
587
- </button>
588
- <button
589
- class="range-btn ${this.selectedRange === 'M' ? 'selected' : ''}"
590
- @click=${() => this.setRange('M')}
591
- >
592
- M
593
- </button>
594
- <button
595
- class="range-btn ${this.selectedRange === 'Y' ? 'selected' : ''}"
596
- @click=${() => this.setRange('Y')}
597
- >
598
- Y
599
- </button>
600
- <button
601
- class="range-btn ${this.selectedRange === 'ALL'
602
- ? 'selected'
603
- : ''}"
604
- @click=${() => this.setRange('ALL')}
605
- >
606
- All
607
- </button>
633
+ <div class="button-group" role="group" aria-label="Period">
634
+ ${RangePicker.RANGES.map(
635
+ (range) =>
636
+ html`<button
637
+ class="range-btn ${this.selectedRange === range.type
638
+ ? 'selected'
639
+ : ''}"
640
+ title=${range.title}
641
+ aria-pressed=${this.selectedRange === range.type}
642
+ @click=${() => this.setRange(range.type)}
643
+ >
644
+ ${range.label}
645
+ </button>`
646
+ )}
608
647
  </div>
609
648
  <button
610
649
  class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
611
650
  ?disabled=${!this.canNavigateNext()}
612
651
  @click=${this.navigateNext}
613
- title="Next ${this.selectedRange === 'W'
614
- ? 'week'
615
- : this.selectedRange === 'M'
616
- ? 'month'
617
- : this.selectedRange === 'Y'
618
- ? 'year'
619
- : this.selectedRange === ''
620
- ? this.getNavigationLabel('next')
621
- : 'period'}"
652
+ title=${this.getNavigationTitle('next')}
653
+ aria-label=${this.getNavigationTitle('next')}
622
654
  >
623
-
655
+ <temba-icon name=${Icon.arrow_right}></temba-icon>
624
656
  </button>
625
657
  </div>
626
658
  </div>
package/src/interfaces.ts CHANGED
@@ -47,6 +47,14 @@ export interface Attachment {
47
47
  error: string;
48
48
  }
49
49
 
50
+ /** A quick reply as stored/served by the server (engine shape) —
51
+ * `text` buttons carry text, `location` requests may omit it. */
52
+ export interface QuickReply {
53
+ type?: string;
54
+ text?: string;
55
+ extra?: string;
56
+ }
57
+
50
58
  export enum DateStyle {
51
59
  DayFirst = 'day_first',
52
60
  MonthFirst = 'month_first',
@@ -231,8 +239,9 @@ export interface Broadcast {
231
239
  /** Base-language attachments (`{content_type, url}` objects or
232
240
  * `contentType:url` strings). */
233
241
  attachments?: (string | Attachment)[];
234
- /** Base-language quick replies. */
235
- quick_replies?: string[];
242
+ /** Base-language quick replies — engine `{text}` objects, with
243
+ * plain strings tolerated for older data. */
244
+ quick_replies?: (string | QuickReply)[];
236
245
  /** The opt-in the broadcast requests, when it is one. */
237
246
  optin?: ObjectReference | null;
238
247
  /** The WhatsApp template the broadcast sends, when it uses one. */
@@ -271,7 +280,7 @@ export interface Msg {
271
280
  text: string;
272
281
  status: string;
273
282
  channel: ObjectReference;
274
- quick_replies: string[];
283
+ quick_replies: (string | QuickReply)[];
275
284
  urn: string;
276
285
  /** The message's contact — populated by the messages CRUDL
277
286
  * endpoint. Carries whichever of name/urn the endpoint exposes. */
@@ -479,6 +488,7 @@ export enum CustomEventType {
479
488
  StoreUpdated = 'temba-store-updated',
480
489
  Ready = 'temba-ready',
481
490
  OrderChanged = 'temba-order-changed',
491
+ ColumnOrderChanged = 'temba-column-order-changed',
482
492
  DragStart = 'temba-drag-start',
483
493
  DragStop = 'temba-drag-stop',
484
494
  DragExternal = 'temba-drag-external',
@@ -872,7 +872,9 @@ export class BroadcastList extends ContentList<Broadcast> {
872
872
  ${broadcast.quick_replies.map(
873
873
  (reply) =>
874
874
  html`<temba-label type="neutral"
875
- >${reply}</temba-label
875
+ >${typeof reply === 'string'
876
+ ? reply
877
+ : (reply.text ?? reply.type)}</temba-label
876
878
  >`
877
879
  )}
878
880
  </div>`
@@ -3,13 +3,19 @@ import { property, state } from 'lit/decorators.js';
3
3
  import { ContentList, ContentListColumn } from './ContentList';
4
4
  import { Icon } from '../Icons';
5
5
  import { Contact } from '../interfaces';
6
- import { getUrl } from '../utils';
6
+ import { getUrl, postJSON } from '../utils';
7
+ import { getStore } from '../store/Store';
7
8
 
8
9
  const FIELD_PREFIX = 'field:';
9
10
 
10
11
  /** Placeholder shown in any cell whose value is empty. */
11
12
  const EMPTY = '--';
12
13
 
14
+ /** Ceiling on how many pages of fields we'll walk. The API serves 250
15
+ * per page, so this is far past any real workspace — it exists only so
16
+ * an endpoint that keeps handing back a `next` can't spin forever. */
17
+ const MAX_FIELD_PAGES = 20;
18
+
13
19
  /**
14
20
  * Contact CRUDL list — drop-in replacement for the rapidpro
15
21
  * `contacts/contact_list.html` table. Each row carries a contact
@@ -22,6 +28,11 @@ const EMPTY = '--';
22
28
  * {@link ContactList.fieldsEndpoint} on connect; cells read each
23
29
  * contact's value out of `item.fields[<key>]`. Date/time fields
24
30
  * render as a relative duration, matching the Last-seen column.
31
+ *
32
+ * When {@link ContactList.priorityEndpoint} is set the field columns
33
+ * can be dragged into a new order, which is saved as the workspace's
34
+ * featured-field order (the list renders featured fields by priority,
35
+ * so column order and field order are the same thing).
25
36
  */
26
37
  export class ContactList extends ContentList<Contact> {
27
38
  static get styles() {
@@ -45,10 +56,25 @@ export class ContactList extends ContentList<Contact> {
45
56
  }
46
57
 
47
58
  /** Endpoint returning `{ results: ContactField[] }`. Fields where
48
- * `featured: true` become extra columns. */
59
+ * `featured: true` become extra columns.
60
+ *
61
+ * The list deliberately fetches fields itself instead of reading the
62
+ * global store: it has to work standalone (and against a custom
63
+ * endpoint), and it may mount before any store exists on the page.
64
+ * The store, when present, is only *told* about changes — see the
65
+ * refresh after a priority save. The endpoint has no featured filter,
66
+ * so the fetch pages through all fields and filters client-side. */
49
67
  @property({ type: String, attribute: 'fields-endpoint' })
50
68
  fieldsEndpoint = '/api/v2/fields.json';
51
69
 
70
+ /** Endpoint accepting `{ featured: [keys...] }` to save featured-field
71
+ * order (the same one the fields management page posts to). When set,
72
+ * the field columns become drag-reorderable and a drop saves the new
73
+ * order org-wide; the host only sets it for users holding the
74
+ * update-priority permission. */
75
+ @property({ type: String, attribute: 'priority-endpoint' })
76
+ priorityEndpoint = '';
77
+
52
78
  /** Anonymous workspaces mask URN values, so instead of the URN
53
79
  * column the list shows each contact's ref (served as its own
54
80
  * anon-only key by the endpoint). */
@@ -104,7 +130,7 @@ export class ContactList extends ContentList<Contact> {
104
130
  // Rebuilding here (rather than in updated()) means a mounted
105
131
  // anon attribute is reflected in the first paint instead of
106
132
  // flashing the URN header for a frame.
107
- if (changes.has('anon')) {
133
+ if (changes.has('anon') || changes.has('priorityEndpoint')) {
108
134
  this.columns = this.buildColumns();
109
135
  }
110
136
  }
@@ -127,11 +153,27 @@ export class ContactList extends ContentList<Contact> {
127
153
  const controller = new AbortController();
128
154
  this.pendingFieldsController = controller;
129
155
  try {
130
- const response = await getUrl(this.fieldsEndpoint, controller);
131
- // If the controller has been swapped or cleared, the response
132
- // is from a stale request drop it on the floor.
133
- if (this.pendingFieldsController !== controller) return;
134
- const all = response.json?.results || [];
156
+ // The fields API is cursor paginated (250 per page), and the
157
+ // featured list a drop posts back is authoritative any featured
158
+ // field we never read would be silently un-featured org-wide. So
159
+ // walk every page before deciding what's featured. Nothing is
160
+ // assigned until the whole walk lands, so a page that fails leaves
161
+ // the previous (complete) list in place rather than a truncated one.
162
+ const all: any[] = [];
163
+ const fetched = new Set<string>();
164
+ let url: string = this.fieldsEndpoint;
165
+ for (let page = 0; url && page < MAX_FIELD_PAGES; page++) {
166
+ // an endpoint whose `next` points back at a page we've already
167
+ // read would otherwise loop until the page cap
168
+ if (fetched.has(url)) break;
169
+ fetched.add(url);
170
+ const response = await getUrl(url, controller);
171
+ // If the controller has been swapped or cleared, the response
172
+ // is from a stale request — drop it on the floor.
173
+ if (this.pendingFieldsController !== controller) return;
174
+ all.push(...(response.json?.results || []));
175
+ url = response.json?.next || '';
176
+ }
135
177
  this.featuredFields = all
136
178
  .filter((f: any) => f.featured)
137
179
  .sort((a: any, b: any) => (b.priority ?? 0) - (a.priority ?? 0));
@@ -173,7 +215,11 @@ export class ContactList extends ContentList<Contact> {
173
215
  sortable: true,
174
216
  resizeMinWidth: '40px',
175
217
  maxWidth: '200px',
176
- resizable: true
218
+ resizable: true,
219
+ // Field columns can be dragged into a new order, but only when
220
+ // the host wired up somewhere to save it — reordering that
221
+ // silently reverts on reload would read as broken.
222
+ reorderable: !!this.priorityEndpoint
177
223
  })
178
224
  );
179
225
  // Name + URN are the pinned identity columns and are not
@@ -224,6 +270,64 @@ export class ContactList extends ContentList<Contact> {
224
270
  ];
225
271
  }
226
272
 
273
+ /** A committed header drag reordered the field columns — keep
274
+ * featuredFields aligned with the new column order (so any rebuild
275
+ * preserves it) and save it as the workspace's featured-field order.
276
+ * Column order maps directly onto priority: leftmost = highest, the
277
+ * same contract as the fields management page, whose endpoint this
278
+ * posts to with the full featured list. */
279
+ protected onColumnOrderChanged(columns: ContentListColumn[]): void {
280
+ // A fields fetch still in flight was started against the old order
281
+ // and would land on top of the drop, snapping the columns back with
282
+ // the new order already on its way to the server.
283
+ if (this.pendingFieldsController) {
284
+ this.pendingFieldsController.abort();
285
+ this.pendingFieldsController = undefined;
286
+ }
287
+ const keys = columns
288
+ .filter((c) => c.key.startsWith(FIELD_PREFIX))
289
+ .map((c) => c.key.substring(FIELD_PREFIX.length));
290
+ this.featuredFields = keys
291
+ .map((key) => (this.featuredFields || []).find((f: any) => f.key === key))
292
+ .filter(Boolean);
293
+ if (!this.priorityEndpoint) return;
294
+ postJSON(this.priorityEndpoint, { featured: keys })
295
+ .then((response) => {
296
+ // postUrl only rejects on 5xx, so a 400/403/404 arrives here as a
297
+ // perfectly resolved promise — the order was never stored and we
298
+ // have to treat it as the failure it is.
299
+ if (response.status < 200 || response.status >= 300) {
300
+ throw response;
301
+ }
302
+ })
303
+ .catch((error) => {
304
+ console.warn('failed to save featured field order', error);
305
+ // The list went away while the save was in flight — there are no
306
+ // columns left to put back, and refetching would only leave a
307
+ // request outstanding against a detached element.
308
+ if (!this.isConnected) return;
309
+ // Nothing made the dragged order durable, so stop showing it —
310
+ // refetch and fall back on whatever the server still holds. The
311
+ // success path deliberately doesn't refetch: we already display
312
+ // exactly what we just saved, and a read-replica answering with
313
+ // pre-write data would yank the columns back for no reason.
314
+ this.loadFields();
315
+ })
316
+ .finally(() => {
317
+ // Featured fields are workspace state other components on the
318
+ // page read from the store (contact details, the fields page),
319
+ // so its cached copy has to hear about the new order too. Looked
320
+ // up here rather than cached on connect so a list that mounted
321
+ // ahead of the store still finds it.
322
+ getStore()
323
+ ?.refreshFields()
324
+ .catch(() => {
325
+ // a stale store cache is cosmetic and elsewhere; the list
326
+ // itself is already showing the saved order
327
+ });
328
+ });
329
+ }
330
+
227
331
  protected getRowIcon(_item: Contact): string | null {
228
332
  return Icon.contact;
229
333
  }