@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.
- package/CHANGELOG.md +21 -0
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +636 -516
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +2 -2
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/flow/Editor.ts +202 -42
- package/src/flow/dependencies.ts +115 -0
- package/src/form/Compose.ts +37 -28
- package/src/form/RangePicker.ts +129 -97
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +3 -1
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +358 -9
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/FlowList.ts +82 -0
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/ContactChat.ts +7 -1
- package/src/live/Realtime.ts +22 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/simulator/Simulator.ts +1 -0
- package/src/store/AppState.ts +97 -10
- package/src/store/Store.ts +495 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
package/src/list/ContentList.ts
CHANGED
|
@@ -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
|
-
/*
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
2900
|
-
//
|
|
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
|
-
|
|
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)}>
|
package/src/list/ContentMenu.ts
CHANGED
|
@@ -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 {
|
package/src/list/FlowList.ts
CHANGED
|
@@ -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':
|