@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.
@@ -44,6 +44,15 @@ export interface ContentListColumn {
44
44
  /** Whether the column can be resized from its trailing edge. Resizing is
45
45
  * opt-in so list types can expose it only where the interaction is useful. */
46
46
  resizable?: boolean;
47
+ /** Whether the column can be reordered by dragging its header.
48
+ * Reorderable columns must form one contiguous run — drags are
49
+ * constrained to slots inside that run so the system columns on
50
+ * either side stay put. Takes effect once two or more columns opt
51
+ * in (there's nothing to reorder against otherwise). The table does
52
+ * not scroll while a drag is in flight, so dragging beyond the
53
+ * visible frame isn't supported — drops clamp to the headers on
54
+ * screen. */
55
+ reorderable?: boolean;
47
56
  /** Optional resize-only floor. Unlike minWidth, this does not affect the
48
57
  * table's native layout before the user resizes the column. */
49
58
  resizeMinWidth?: string;
@@ -523,10 +532,16 @@ export class ContentList<T = any> extends RapidElement {
523
532
  within the panel padding so row dividers don't bleed full-
524
533
  width. */
525
534
  margin-right: -20px;
526
- /* Contain the sticky header's z-index inside this frame so it
527
- can't compete with the page header's content-menu dropdown
528
- (also z-index 2), which otherwise paints under the table
529
- header by DOM-order tie-break. */
535
+ /* Keep the table's internal layering (sticky header, pinned
536
+ columns and their hover/shadow layers, z-index 1-6) inside
537
+ its own stacking context so those values can only order
538
+ against each other and never interleave with page-level
539
+ layers like floating windows or open dropdowns. Without it
540
+ the frame is z-index auto, so the table's 1-6 would join the
541
+ nearest ancestor stacking context — normally the root — and
542
+ tie against page chrome using comparably small values. Note
543
+ this also caps this list's own label dropdown, which renders
544
+ inside the frame and so cannot escape it. */
530
545
  isolation: isolate;
531
546
  }
532
547
  .table-scroll {
@@ -767,6 +782,52 @@ export class ContentList<T = any> extends RapidElement {
767
782
  cursor: col-resize !important;
768
783
  }
769
784
 
785
+ /* Column reorder. A reorderable header is a drag surface: past a
786
+ small pointer threshold the label lifts into a floating ghost,
787
+ the origin column dims, and an accent insertion bar tracks the
788
+ drop slot. touch-action: none lets the same drag work on touch
789
+ — the 36px sticky header row is a negligible scroll-start
790
+ surface, so claiming its gestures (including the vertical
791
+ swipe that would otherwise scroll the list) costs little. */
792
+ .head-cell.reorderable {
793
+ touch-action: none;
794
+ }
795
+ /* Advertise the drag while idle. The th prefix out-specifies
796
+ .head-cell.sortable's pointer cursor, which is declared later
797
+ in this sheet and would otherwise win on a sortable column. */
798
+ th.head-cell.reorderable {
799
+ cursor: grab;
800
+ }
801
+ .head-cell.dragging {
802
+ opacity: 0.35;
803
+ }
804
+ /* The insertion bar sits flush inside the boundary it marks
805
+ rather than straddling it — an overhanging bar would be painted
806
+ over by the neighbouring header cell, which is opaque and wins
807
+ the stacking order. The header cell is the positioning context
808
+ (it's position: sticky). */
809
+ .head-cell.drop-before::before,
810
+ .head-cell.drop-after::after {
811
+ content: '';
812
+ position: absolute;
813
+ top: 4px;
814
+ bottom: 4px;
815
+ width: 3px;
816
+ border-radius: 2px;
817
+ background: var(--accent-400);
818
+ z-index: 5;
819
+ }
820
+ .head-cell.drop-before::before {
821
+ left: 0;
822
+ }
823
+ .head-cell.drop-after::after {
824
+ right: 0;
825
+ }
826
+ :host([column-dragging]),
827
+ :host([column-dragging]) * {
828
+ cursor: grabbing !important;
829
+ }
830
+
770
831
  /* Pinned columns stay fixed against their edge while the rest
771
832
  of the table scrolls under them. The frozen-region look —
772
833
  the tint and the divider — only kicks in once the table
@@ -1488,6 +1549,43 @@ export class ContentList<T = any> extends RapidElement {
1488
1549
  }
1489
1550
  | undefined;
1490
1551
  private previousBodyUserSelect = '';
1552
+
1553
+ /** Active pointer-driven header drag-reorder. The drag only "starts"
1554
+ * once the pointer travels past a small threshold, so a plain click
1555
+ * on a reorderable (and typically sortable) header still sorts. */
1556
+ private columnDrag:
1557
+ | {
1558
+ key: string;
1559
+ pointerId: number;
1560
+ startX: number;
1561
+ grabOffsetX: number;
1562
+ started: boolean;
1563
+ header: HTMLElement;
1564
+ ghost?: HTMLElement;
1565
+ }
1566
+ | undefined;
1567
+
1568
+ /** The drag keeps its own copy of body user-select: a resize started
1569
+ * mid-drag cancels the drag, and sharing one field would let whichever
1570
+ * finished last restore the other's (already overwritten) value. */
1571
+ private previousBodyUserSelectDrag = '';
1572
+
1573
+ /** Pointer travel, in px, that separates a click (sort) from a drag
1574
+ * (reorder). Only horizontal travel counts — that's the axis the
1575
+ * reorder happens on. */
1576
+ private static readonly DRAG_DEAD_ZONE = 5;
1577
+
1578
+ /** Key of the column being drag-reordered, '' when idle — drives the
1579
+ * dimmed treatment on the origin header. */
1580
+ @state()
1581
+ private draggingColumnKey = '';
1582
+
1583
+ /** Insertion slot the drag is currently over: the `columns` index
1584
+ * the dragged column would be inserted before (one past the
1585
+ * reorderable run for an end drop), or -1 when idle. */
1586
+ @state()
1587
+ private columnDropSlot = -1;
1588
+
1491
1589
  private columnWidths: Record<string, number> = {};
1492
1590
  private columnWidthSaveTimeout: ReturnType<typeof setTimeout> = null;
1493
1591
  private suppressHeaderClick = false;
@@ -1517,6 +1615,12 @@ export class ContentList<T = any> extends RapidElement {
1517
1615
  super();
1518
1616
  }
1519
1617
 
1618
+ /** Gives specialized lists a chance to normalize a fetched page before it
1619
+ * becomes visible. The default keeps the server response unchanged. */
1620
+ protected prepareItems(items: T[]): T[] {
1621
+ return items;
1622
+ }
1623
+
1520
1624
  protected willUpdate(changes: PropertyValues): void {
1521
1625
  super.willUpdate(changes);
1522
1626
  if (
@@ -1610,6 +1714,7 @@ export class ContentList<T = any> extends RapidElement {
1610
1714
  window.removeEventListener('resize', this.resizeHandler);
1611
1715
  }
1612
1716
  this.stopColumnResize();
1717
+ this.cancelColumnDrag();
1613
1718
  if (this.columnWidthSaveTimeout) {
1614
1719
  clearTimeout(this.columnWidthSaveTimeout);
1615
1720
  this.columnWidthSaveTimeout = null;
@@ -1922,7 +2027,7 @@ export class ContentList<T = any> extends RapidElement {
1922
2027
  // empty results) with an `error` message — surface it over the
1923
2028
  // empty table rather than the plain empty-state copy.
1924
2029
  this.searchError = typeof data.error === 'string' ? data.error : '';
1925
- this.items = data.results || [];
2030
+ this.items = this.prepareItems(data.results || []);
1926
2031
  this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
1927
2032
  this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
1928
2033
  // Cursor mode is detected from the shape of next/previous,
@@ -2896,9 +3001,11 @@ export class ContentList<T = any> extends RapidElement {
2896
3001
  column: ContentListColumn
2897
3002
  ): void {
2898
3003
  if (event.pointerType === 'mouse' && event.button !== 0) return;
2899
- // A second pointer should replace, not overlap, an active drag and
2900
- // must preserve the body's original selection style for cleanup.
3004
+ // A second pointer should replace, not overlap, an active resize or
3005
+ // reorder both stash the body's selection style, so leaving one
3006
+ // running would strand `user-select: none` on the body.
2901
3007
  this.stopColumnResize();
3008
+ this.cancelColumnDrag();
2902
3009
  event.preventDefault();
2903
3010
  event.stopPropagation();
2904
3011
  const handle = event.currentTarget as HTMLElement;
@@ -2985,6 +3092,212 @@ export class ContentList<T = any> extends RapidElement {
2985
3092
  if (changed) this.scheduleColumnWidthSave();
2986
3093
  };
2987
3094
 
3095
+ /** The contiguous run of reorderable columns as [first, last]
3096
+ * indexes, or null when fewer than two columns opt in. Mirroring the
3097
+ * pinning contract, reorderable columns must be contiguous — drops
3098
+ * are constrained to slots inside this run. */
3099
+ private reorderableRange(): [number, number] | null {
3100
+ const first = this.columns.findIndex((c) => c.reorderable);
3101
+ if (first === -1) return null;
3102
+ let last = first;
3103
+ while (
3104
+ last + 1 < this.columns.length &&
3105
+ this.columns[last + 1].reorderable
3106
+ ) {
3107
+ last++;
3108
+ }
3109
+ return last > first ? [first, last] : null;
3110
+ }
3111
+
3112
+ private headerCellForColumn(key: string): HTMLElement | null {
3113
+ return this.shadowRoot?.querySelector(
3114
+ `th.head-cell[data-key="${CSS.escape(key)}"]`
3115
+ );
3116
+ }
3117
+
3118
+ private startColumnDrag(
3119
+ event: PointerEvent,
3120
+ column: ContentListColumn
3121
+ ): void {
3122
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
3123
+ if (this.columnResize || this.columnDrag) return;
3124
+ // No range check here: this only ever runs from the @pointerdown
3125
+ // render binds on headers inside the reorderable run.
3126
+ const header = event.currentTarget as HTMLElement;
3127
+ // No preventDefault and no pointer capture yet — until the pointer
3128
+ // clears the dead zone this may still be a plain sort click, which
3129
+ // must reach the header untouched.
3130
+ this.columnDrag = {
3131
+ key: column.key,
3132
+ pointerId: event.pointerId,
3133
+ startX: event.clientX,
3134
+ grabOffsetX: event.clientX - header.getBoundingClientRect().left,
3135
+ started: false,
3136
+ header
3137
+ };
3138
+ window.addEventListener('pointermove', this.handleColumnDragMove);
3139
+ window.addEventListener('pointerup', this.stopColumnDrag);
3140
+ window.addEventListener('pointercancel', this.cancelColumnDrag);
3141
+ }
3142
+
3143
+ private handleColumnDragMove = (event: PointerEvent): void => {
3144
+ const drag = this.columnDrag;
3145
+ if (!drag) return;
3146
+ // A second (e.g. touch) pointer must not drive a drag it didn't
3147
+ // start.
3148
+ if (event.pointerId !== drag.pointerId) return;
3149
+ if (!drag.started) {
3150
+ if (Math.abs(event.clientX - drag.startX) < ContentList.DRAG_DEAD_ZONE) {
3151
+ return;
3152
+ }
3153
+ drag.started = true;
3154
+ // Capture so the drag survives excursions outside the window and
3155
+ // the release's synthesized click retargets to the origin header,
3156
+ // where the post-drag suppression can consume it.
3157
+ try {
3158
+ drag.header.setPointerCapture(drag.pointerId);
3159
+ } catch {
3160
+ // synthetic pointers (tests) have no active pointer to capture
3161
+ }
3162
+ drag.ghost = this.createColumnDragGhost(drag.header);
3163
+ this.draggingColumnKey = drag.key;
3164
+ this.previousBodyUserSelectDrag = document.body.style.userSelect;
3165
+ document.body.style.userSelect = 'none';
3166
+ this.toggleAttribute('column-dragging', true);
3167
+ }
3168
+ event.preventDefault();
3169
+ if (drag.ghost) {
3170
+ drag.ghost.style.left = `${event.clientX - drag.grabOffsetX}px`;
3171
+ }
3172
+ this.columnDropSlot = this.computeColumnDropSlot(event.clientX);
3173
+ };
3174
+
3175
+ /** Where a drop at clientX would insert the dragged column: the
3176
+ * `columns` index to insert before, clamped to the reorderable run. */
3177
+ private computeColumnDropSlot(clientX: number): number {
3178
+ const range = this.reorderableRange();
3179
+ if (!range) return -1;
3180
+ const [first, last] = range;
3181
+ let slot = first;
3182
+ for (let i = first; i <= last; i++) {
3183
+ const header = this.headerCellForColumn(this.columns[i].key);
3184
+ if (!header) continue;
3185
+ const rect = header.getBoundingClientRect();
3186
+ if (clientX > rect.left + rect.width / 2) slot = i + 1;
3187
+ }
3188
+ return slot;
3189
+ }
3190
+
3191
+ private stopColumnDrag = (event?: Event): void => {
3192
+ const drag = this.columnDrag;
3193
+ if (!drag) return;
3194
+ // Ignore the release of any pointer other than the one that started
3195
+ // the drag. Teardown paths without an event (disconnect) still run.
3196
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3197
+ return;
3198
+ }
3199
+ const slot = this.columnDropSlot;
3200
+ this.teardownColumnDrag();
3201
+ if (!drag.started) return;
3202
+ // The release retargets a synthesized click at the captured origin
3203
+ // header; consume it so completing a reorder never also re-sorts.
3204
+ if (event?.type === 'pointerup') {
3205
+ this.suppressHeaderClick = true;
3206
+ clearTimeout(this.headerClickSuppressionTimeout);
3207
+ this.headerClickSuppressionTimeout = setTimeout(() => {
3208
+ this.suppressHeaderClick = false;
3209
+ this.headerClickSuppressionTimeout = null;
3210
+ }, 0);
3211
+ }
3212
+ const from = this.columns.findIndex((c) => c.key === drag.key);
3213
+ if (from === -1 || slot < 0) return;
3214
+ const to = slot > from ? slot - 1 : slot;
3215
+ if (to === from) return;
3216
+ const columns = [...this.columns];
3217
+ const [moved] = columns.splice(from, 1);
3218
+ columns.splice(to, 0, moved);
3219
+ this.columns = columns;
3220
+ // Its own event type: the generic OrderChanged is a bubbling,
3221
+ // composed event whose other producers carry different payloads
3222
+ // (`ids`, `swap`), and its listeners would choke on this one.
3223
+ this.fireCustomEvent(CustomEventType.ColumnOrderChanged, {
3224
+ keys: columns.map((c) => c.key),
3225
+ from,
3226
+ to
3227
+ });
3228
+ this.onColumnOrderChanged(columns);
3229
+ };
3230
+
3231
+ /** Abandons the drag without committing a drop. Doubles as the
3232
+ * pointercancel listener (where it only answers to its own pointer)
3233
+ * and as the unconditional cleanup hook for disconnect / resize. */
3234
+ private cancelColumnDrag = (event?: Event): void => {
3235
+ const drag = this.columnDrag;
3236
+ if (!drag) return;
3237
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3238
+ return;
3239
+ }
3240
+ this.teardownColumnDrag();
3241
+ };
3242
+
3243
+ private teardownColumnDrag(): void {
3244
+ const drag = this.columnDrag;
3245
+ this.columnDrag = undefined;
3246
+ this.columnDropSlot = -1;
3247
+ this.draggingColumnKey = '';
3248
+ window.removeEventListener('pointermove', this.handleColumnDragMove);
3249
+ window.removeEventListener('pointerup', this.stopColumnDrag);
3250
+ window.removeEventListener('pointercancel', this.cancelColumnDrag);
3251
+ if (drag?.started) {
3252
+ document.body.style.userSelect = this.previousBodyUserSelectDrag;
3253
+ this.toggleAttribute('column-dragging', false);
3254
+ }
3255
+ drag?.ghost?.remove();
3256
+ }
3257
+
3258
+ /** Called after a header drag commits a new column order. Subclasses
3259
+ * persist it (e.g. the contact list saves featured-field priorities). */
3260
+ protected onColumnOrderChanged(_columns: ContentListColumn[]): void {}
3261
+
3262
+ /** A floating copy of the header label that tracks the pointer during
3263
+ * a reorder. It lives on document.body — outside the shadow root —
3264
+ * so every style is inlined, with literal fallbacks for the design
3265
+ * tokens in case the host page doesn't define them globally. */
3266
+ private createColumnDragGhost(header: HTMLElement): HTMLElement {
3267
+ const rect = header.getBoundingClientRect();
3268
+ const style = getComputedStyle(header);
3269
+ const ghost = document.createElement('div');
3270
+ ghost.className = 'column-drag-ghost';
3271
+ // Purely decorative — it duplicates the header it was lifted from.
3272
+ ghost.setAttribute('aria-hidden', 'true');
3273
+ ghost.textContent =
3274
+ header.querySelector('.label')?.textContent?.trim() ?? '';
3275
+ Object.assign(ghost.style, {
3276
+ position: 'fixed',
3277
+ top: `${rect.top}px`,
3278
+ left: `${rect.left}px`,
3279
+ height: `${rect.height}px`,
3280
+ minWidth: `${rect.width}px`,
3281
+ display: 'flex',
3282
+ alignItems: 'center',
3283
+ boxSizing: 'border-box',
3284
+ padding: '0 8px',
3285
+ font: style.font,
3286
+ letterSpacing: style.letterSpacing,
3287
+ textTransform: style.textTransform,
3288
+ color: style.color,
3289
+ background: 'var(--surface, #fff)',
3290
+ border: '1px solid var(--border, #e4e7ec)',
3291
+ borderRadius: 'var(--curvature, 6px)',
3292
+ boxShadow: 'var(--shadow-1, 0 2px 6px rgba(0, 0, 0, 0.12))',
3293
+ pointerEvents: 'none',
3294
+ whiteSpace: 'nowrap',
3295
+ zIndex: '10000'
3296
+ });
3297
+ document.body.appendChild(ghost);
3298
+ return ghost;
3299
+ }
3300
+
2988
3301
  /** Arrow keys resize in 10px steps (25px with Shift), providing an
2989
3302
  * accessible equivalent to dragging the separator. */
2990
3303
  private handleColumnResizeKeydown(
@@ -3244,6 +3557,12 @@ export class ContentList<T = any> extends RapidElement {
3244
3557
  const allSelected =
3245
3558
  allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
3246
3559
  const someSelected = !allSelected && this.selectedIds.size > 0;
3560
+ // Computed once for the whole row rather than per cell — every
3561
+ // header cell needs both to resolve its reorder affordances.
3562
+ const reorderableRange = this.reorderableRange();
3563
+ const dragFromIndex = this.draggingColumnKey
3564
+ ? this.columns.findIndex((c) => c.key === this.draggingColumnKey)
3565
+ : -1;
3247
3566
 
3248
3567
  return html`
3249
3568
  <thead>
@@ -3281,6 +3600,9 @@ export class ContentList<T = any> extends RapidElement {
3281
3600
  const outerResize = index === this.columns.length - 1;
3282
3601
  return html`${this.renderHeaderCell(
3283
3602
  column,
3603
+ index,
3604
+ reorderableRange,
3605
+ dragFromIndex,
3284
3606
  leadingResizeColumn,
3285
3607
  trailingResize,
3286
3608
  outerResize
@@ -3328,16 +3650,39 @@ export class ContentList<T = any> extends RapidElement {
3328
3650
 
3329
3651
  private renderHeaderCell(
3330
3652
  column: ContentListColumn,
3653
+ index: number,
3654
+ range: [number, number] | null,
3655
+ fromIndex: number,
3331
3656
  leadingResizeColumn?: ContentListColumn,
3332
3657
  trailingResize = false,
3333
3658
  outerResize = false
3334
3659
  ): TemplateResult {
3335
3660
  const active = this.sort === column.key || this.sort === '-' + column.key;
3336
3661
  const desc = this.sort === '-' + column.key;
3662
+ // Reorder affordances: a column is only draggable when it sits in
3663
+ // the reorderable run of two or more — a stray `reorderable` column
3664
+ // outside that run has nowhere to go, so it gets no drag. During a
3665
+ // drag the origin header dims and an insertion bar marks the current
3666
+ // drop slot — suppressed when the slot would put the column right
3667
+ // back where it already is (a no-op drop needs no affordance).
3668
+ const reorderable = !!(
3669
+ column.reorderable &&
3670
+ range &&
3671
+ index >= range[0] &&
3672
+ index <= range[1]
3673
+ );
3674
+ const dragging = this.draggingColumnKey === column.key;
3675
+ const dropSlot = fromIndex === -1 ? -1 : this.columnDropSlot;
3676
+ const noopSlot = dropSlot === fromIndex || dropSlot === fromIndex + 1;
3677
+ const dropBefore = !noopSlot && dropSlot === index && reorderable;
3678
+ const dropAfter =
3679
+ !noopSlot && !!range && dropSlot === range[1] + 1 && index === range[1];
3337
3680
  const cls = `head-cell ${column.align || ''} ${
3338
3681
  column.sortable ? 'sortable' : ''
3339
- } ${active ? 'active' : ''} ${
3340
- column.grow ? 'grow' : ''
3682
+ } ${active ? 'active' : ''} ${column.grow ? 'grow' : ''} ${
3683
+ reorderable ? 'reorderable' : ''
3684
+ } ${dragging ? 'dragging' : ''} ${dropBefore ? 'drop-before' : ''} ${
3685
+ dropAfter ? 'drop-after' : ''
3341
3686
  } ${this.columnPinClass(column)}`;
3342
3687
  // The sort arrow sits on the inboard side of the label — left of
3343
3688
  // it for right-aligned columns, right of it otherwise — so the
@@ -3371,10 +3716,14 @@ export class ContentList<T = any> extends RapidElement {
3371
3716
  return html`
3372
3717
  <th
3373
3718
  class=${cls}
3719
+ data-key=${column.key}
3374
3720
  style="${this.columnPinStyle(column)} ${widthStyle}"
3375
3721
  @click=${column.sortable
3376
3722
  ? (event: MouseEvent) => this.handleColumnHeaderClick(event, column)
3377
3723
  : null}
3724
+ @pointerdown=${reorderable
3725
+ ? (event: PointerEvent) => this.startColumnDrag(event, column)
3726
+ : null}
3378
3727
  >
3379
3728
  ${this.renderResizeHandle(leadingResizeColumn, true)}
3380
3729
  <div class="head-inner" style=${this.cellWidthStyle(column)}>
@@ -34,15 +34,23 @@ export enum ContentMenuItemType {
34
34
  export class ContentMenu extends RapidElement {
35
35
  static get styles() {
36
36
  return css`
37
- :host {
38
- tabindex: 0;
39
- z-index: 5000;
40
- }
41
37
  .container {
42
38
  display: flex;
43
39
  align-items: center;
44
40
  }
45
41
 
42
+ temba-dropdown {
43
+ /* as a flex item of .container this z-index takes effect and
44
+ creates a stacking context — keep it in sync with the popup's
45
+ z-index inside temba-dropdown (9000) so the open menu clears
46
+ floating windows like the simulator (5000). This also keeps
47
+ the closed toggle above those windows, intentionally: the
48
+ menu has to stay clickable when a floating window overlaps
49
+ the header. Scoped here rather than on :host so the action
50
+ buttons rendered alongside keep their normal stacking. */
51
+ z-index: 9000;
52
+ }
53
+
46
54
  .button_item,
47
55
  .primary_button_item {
48
56
  margin-left: 1rem;
@@ -68,7 +76,6 @@ export class ContentMenu extends RapidElement {
68
76
  color: rgb(45, 45, 45);
69
77
  z-index: 50;
70
78
  min-width: 200px;
71
- tabindex: 0;
72
79
  }
73
80
 
74
81
  .divider {
@@ -82,7 +89,6 @@ export class ContentMenu extends RapidElement {
82
89
  font-size: 1.1rem;
83
90
  cursor: pointer;
84
91
  font-weight: 400;
85
- tabindex: 0;
86
92
  }
87
93
 
88
94
  .item:hover {
@@ -2,6 +2,8 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
4
  import { CustomEventType, Flow, ObjectReference } from '../interfaces';
5
+ import { getStore, StoreAsset, StoreAssetChangedEvent } from '../store/Store';
6
+ import { RealtimeSubscription } from '../live/Realtime';
5
7
 
6
8
  /**
7
9
  * Flow CRUDL list — drop-in replacement for the rapidpro
@@ -11,6 +13,8 @@ import { CustomEventType, Flow, ObjectReference } from '../interfaces';
11
13
  * bar, activity sparkline.
12
14
  */
13
15
  export class FlowList extends ContentList<Flow> {
16
+ private assetWatch: RealtimeSubscription = null;
17
+
14
18
  static get styles() {
15
19
  return css`
16
20
  ${ContentList.styles}
@@ -149,6 +153,84 @@ export class FlowList extends ContentList<Flow> {
149
153
  ];
150
154
  }
151
155
 
156
+ public connectedCallback(): void {
157
+ super.connectedCallback();
158
+ // rows we already have (if any) - each load re-syncs from prepareItems
159
+ this.syncAssetWatch();
160
+ }
161
+
162
+ public disconnectedCallback(): void {
163
+ super.disconnectedCallback();
164
+ if (this.assetWatch) {
165
+ this.assetWatch.unsubscribe();
166
+ this.assetWatch = null;
167
+ }
168
+ }
169
+
170
+ private syncAssetWatch(): void {
171
+ if (!this.isConnected) {
172
+ return;
173
+ }
174
+ const store = getStore();
175
+ if (!store) {
176
+ return;
177
+ }
178
+ if (this.assetWatch) {
179
+ this.assetWatch.unsubscribe();
180
+ }
181
+ this.assetWatch = store.watchAssets(
182
+ (this.items || []).map((flow) => ({ type: 'flow', uuid: flow.uuid })),
183
+ (event: StoreAssetChangedEvent | null) => this.syncFlowNames(event?.asset)
184
+ );
185
+ }
186
+
187
+ private syncFlowNames(changed?: StoreAsset): void {
188
+ const items = this.withCanonicalFlowNames(this.items, changed);
189
+ if (items !== this.items) {
190
+ this.items = items;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A freshly fetched page is authoritative, so it seeds the store cache
196
+ * rather than being overwritten by it - a cached name can predate this
197
+ * response (e.g. a rename missed while another page was on screen), and
198
+ * writing it back would make the stale name self-reinforcing. Cached names
199
+ * only ever move rows through syncFlowNames, driven by socket changes and
200
+ * the reconnect refresh, which are the paths that are actually newer.
201
+ */
202
+ protected prepareItems(items: Flow[]): Flow[] {
203
+ getStore()?.cacheAssets(
204
+ items
205
+ .filter((item) => !!item.uuid && typeof item.name === 'string')
206
+ .map((item) => ({
207
+ type: 'flow',
208
+ uuid: item.uuid,
209
+ name: item.name
210
+ }))
211
+ );
212
+ Promise.resolve().then(() => this.syncAssetWatch());
213
+ return items;
214
+ }
215
+
216
+ private withCanonicalFlowNames(items: Flow[], changed?: StoreAsset): Flow[] {
217
+ const store = getStore();
218
+ let updated = false;
219
+ const canonical = items.map((item) => {
220
+ const asset = changed
221
+ ? changed.uuid === item.uuid
222
+ ? changed
223
+ : null
224
+ : store?.getAsset('flow', item.uuid);
225
+ if (asset && asset.name !== item.name) {
226
+ updated = true;
227
+ return { ...item, name: asset.name };
228
+ }
229
+ return item;
230
+ });
231
+ return updated ? canonical : items;
232
+ }
233
+
152
234
  protected getRowIcon(item: Flow): string | null {
153
235
  switch (item?.type) {
154
236
  case 'voice':