@open-mercato/ui 0.6.7-develop.6552.1.5208a19369 → 0.6.7-develop.6565.1.1026d85fff
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6565.1.1026d85fff",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.7-develop.6565.1.1026d85fff",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.7-develop.6565.1.1026d85fff",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
|
@@ -471,6 +471,12 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
|
|
|
471
471
|
const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
|
|
472
472
|
const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
|
|
473
473
|
|
|
474
|
+
// Bounds for user-driven column resizing (#1835). Widths outside this range are
|
|
475
|
+
// clamped so a persisted/dragged value can never collapse a column to nothing or
|
|
476
|
+
// blow the table out horizontally.
|
|
477
|
+
const COLUMN_MIN_WIDTH = 60
|
|
478
|
+
const COLUMN_MAX_WIDTH = 900
|
|
479
|
+
|
|
474
480
|
function formatDurationLabel(durationMs?: number | null): string {
|
|
475
481
|
if (durationMs == null) return ''
|
|
476
482
|
if (!Number.isFinite(durationMs)) return ''
|
|
@@ -544,7 +550,7 @@ export function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveS
|
|
|
544
550
|
writeVersionedPreference(key, PERSPECTIVE_SNAPSHOT_VERSION, snapshot)
|
|
545
551
|
}
|
|
546
552
|
|
|
547
|
-
function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
|
|
553
|
+
export function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
|
|
548
554
|
if (!source || typeof source !== 'object') return null
|
|
549
555
|
const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
|
|
550
556
|
const result: PerspectiveSettings = {}
|
|
@@ -567,6 +573,17 @@ function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): Persp
|
|
|
567
573
|
}
|
|
568
574
|
}
|
|
569
575
|
|
|
576
|
+
if (source.columnSizing && typeof source.columnSizing === 'object') {
|
|
577
|
+
const sizing: Record<string, number> = {}
|
|
578
|
+
for (const [key, value] of Object.entries(source.columnSizing)) {
|
|
579
|
+
const id = typeof key === 'string' ? key.trim() : ''
|
|
580
|
+
if (!id || forbidden.has(id)) continue
|
|
581
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
582
|
+
sizing[id] = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(value)))
|
|
583
|
+
}
|
|
584
|
+
if (Object.keys(sizing).length) result.columnSizing = sizing
|
|
585
|
+
}
|
|
586
|
+
|
|
570
587
|
if (Array.isArray(source.sorting)) {
|
|
571
588
|
const sorting = source.sorting
|
|
572
589
|
.map((item) => {
|
|
@@ -856,7 +873,99 @@ function HeaderDndWrapper({ enabled, contextId, sensors, columnIds, onDragEnd, c
|
|
|
856
873
|
)
|
|
857
874
|
}
|
|
858
875
|
|
|
859
|
-
|
|
876
|
+
// Drag handle on a column header's right edge (#1835). Uses manual pointer
|
|
877
|
+
// tracking (mirrors the deals-pipeline LaneResizeHandle) rather than TanStack's
|
|
878
|
+
// built-in resize so the table keeps its auto layout — only user-resized columns
|
|
879
|
+
// get an explicit width, and dragging measures the header's real current width so
|
|
880
|
+
// there is no jump. `stopPropagation` on pointer/click keeps the header's
|
|
881
|
+
// reorder-DnD and sort-toggle from firing while resizing.
|
|
882
|
+
function ColumnResizeHandle({
|
|
883
|
+
columnId,
|
|
884
|
+
onResize,
|
|
885
|
+
onCommit,
|
|
886
|
+
onReset,
|
|
887
|
+
ariaLabel,
|
|
888
|
+
}: {
|
|
889
|
+
columnId: string
|
|
890
|
+
onResize: (columnId: string, width: number) => void
|
|
891
|
+
onCommit: () => void
|
|
892
|
+
onReset: (columnId: string) => void
|
|
893
|
+
ariaLabel: string
|
|
894
|
+
}) {
|
|
895
|
+
const [active, setActive] = React.useState(false)
|
|
896
|
+
// Holds the current drag's teardown so an unmount mid-drag (perspective apply,
|
|
897
|
+
// data reload, column-visibility change) still removes the document listeners
|
|
898
|
+
// and restores the body cursor/selection instead of leaking them.
|
|
899
|
+
const dragCleanupRef = React.useRef<(() => void) | null>(null)
|
|
900
|
+
React.useEffect(() => () => { dragCleanupRef.current?.() }, [])
|
|
901
|
+
|
|
902
|
+
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
903
|
+
if (event.button !== 0) return
|
|
904
|
+
event.preventDefault()
|
|
905
|
+
event.stopPropagation()
|
|
906
|
+
const headerCell = event.currentTarget.parentElement as HTMLElement | null
|
|
907
|
+
if (!headerCell) return
|
|
908
|
+
const startX = event.clientX
|
|
909
|
+
const startWidth = headerCell.getBoundingClientRect().width
|
|
910
|
+
setActive(true)
|
|
911
|
+
let frame = 0
|
|
912
|
+
let latest = startWidth
|
|
913
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
914
|
+
latest = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(startWidth + (moveEvent.clientX - startX))))
|
|
915
|
+
if (frame) return
|
|
916
|
+
frame = window.requestAnimationFrame(() => {
|
|
917
|
+
frame = 0
|
|
918
|
+
onResize(columnId, latest)
|
|
919
|
+
})
|
|
920
|
+
}
|
|
921
|
+
const teardown = () => {
|
|
922
|
+
if (frame) window.cancelAnimationFrame(frame)
|
|
923
|
+
document.removeEventListener('pointermove', onMove)
|
|
924
|
+
document.removeEventListener('pointerup', onUp)
|
|
925
|
+
document.body.style.cursor = ''
|
|
926
|
+
document.body.style.userSelect = ''
|
|
927
|
+
dragCleanupRef.current = null
|
|
928
|
+
}
|
|
929
|
+
const onUp = () => {
|
|
930
|
+
onResize(columnId, latest)
|
|
931
|
+
teardown()
|
|
932
|
+
setActive(false)
|
|
933
|
+
onCommit()
|
|
934
|
+
}
|
|
935
|
+
dragCleanupRef.current = teardown
|
|
936
|
+
document.addEventListener('pointermove', onMove)
|
|
937
|
+
document.addEventListener('pointerup', onUp)
|
|
938
|
+
document.body.style.cursor = 'col-resize'
|
|
939
|
+
document.body.style.userSelect = 'none'
|
|
940
|
+
}, [columnId, onResize, onCommit])
|
|
941
|
+
|
|
942
|
+
return (
|
|
943
|
+
<div
|
|
944
|
+
role="separator"
|
|
945
|
+
aria-orientation="vertical"
|
|
946
|
+
aria-label={ariaLabel}
|
|
947
|
+
title={ariaLabel}
|
|
948
|
+
onPointerDown={handlePointerDown}
|
|
949
|
+
onClick={(event) => event.stopPropagation()}
|
|
950
|
+
onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); onReset(columnId) }}
|
|
951
|
+
className="group/resize absolute right-0 top-0 z-10 flex h-full w-3 translate-x-1/2 cursor-col-resize touch-none select-none items-center justify-center"
|
|
952
|
+
>
|
|
953
|
+
{/* Always-visible short grip marks the column edge as resizable; it grows
|
|
954
|
+
to full height and brightens on header/handle hover and while dragging. */}
|
|
955
|
+
<span
|
|
956
|
+
aria-hidden
|
|
957
|
+
className={cn(
|
|
958
|
+
'w-0.5 rounded-full transition-all',
|
|
959
|
+
active
|
|
960
|
+
? 'h-full bg-primary'
|
|
961
|
+
: 'h-3.5 bg-border group-hover:h-full group-hover:bg-border group-hover/resize:h-full group-hover/resize:bg-primary',
|
|
962
|
+
)}
|
|
963
|
+
/>
|
|
964
|
+
</div>
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function SortableHeaderCell({ id, children, className, width }: { id: string; children: React.ReactNode; className?: string; width?: number }) {
|
|
860
969
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
|
861
970
|
const isSticky = typeof className === 'string' && className.includes('sticky')
|
|
862
971
|
const style: React.CSSProperties = {
|
|
@@ -865,6 +974,7 @@ function SortableHeaderCell({ id, children, className }: { id: string; children:
|
|
|
865
974
|
opacity: isDragging ? 0.5 : 1,
|
|
866
975
|
cursor: 'grab',
|
|
867
976
|
position: isSticky ? 'sticky' : 'relative',
|
|
977
|
+
...(typeof width === 'number' ? { width, minWidth: width, maxWidth: width } : {}),
|
|
868
978
|
}
|
|
869
979
|
return (
|
|
870
980
|
<TableHead ref={setNodeRef} style={style} className={className} {...attributes} {...listeners}>
|
|
@@ -1106,6 +1216,11 @@ export function DataTable<T>({
|
|
|
1106
1216
|
const [activePerspectiveId, setActivePerspectiveId] = React.useState<string | null>(initialActiveId)
|
|
1107
1217
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(() => mergedInitialSettings?.columnVisibility ?? {})
|
|
1108
1218
|
const [columnOrder, setColumnOrder] = React.useState<string[]>(() => mergedInitialSettings?.columnOrder ?? [])
|
|
1219
|
+
const [columnSizing, setColumnSizing] = React.useState<Record<string, number>>(() => mergedInitialSettings?.columnSizing ?? {})
|
|
1220
|
+
// Mirror of columnSizing so the resize commit (fired from a pointerup handler
|
|
1221
|
+
// set up on drag start) always reads the latest widths without a stale closure.
|
|
1222
|
+
const columnSizingRef = React.useRef(columnSizing)
|
|
1223
|
+
React.useEffect(() => { columnSizingRef.current = columnSizing }, [columnSizing])
|
|
1109
1224
|
const [deletingIds, setDeletingIds] = React.useState<string[]>([])
|
|
1110
1225
|
const [roleClearingIds, setRoleClearingIds] = React.useState<string[]>([])
|
|
1111
1226
|
const [perspectiveApiMissing, setPerspectiveApiMissing] = React.useState(false)
|
|
@@ -1596,12 +1711,13 @@ export function DataTable<T>({
|
|
|
1596
1711
|
const candidate: PerspectiveSettings = {
|
|
1597
1712
|
columnOrder,
|
|
1598
1713
|
columnVisibility: visibility,
|
|
1714
|
+
columnSizing,
|
|
1599
1715
|
sorting,
|
|
1600
1716
|
filters: filtersPayload,
|
|
1601
1717
|
searchValue,
|
|
1602
1718
|
}
|
|
1603
1719
|
return sanitizePerspectiveSettings(candidate) ?? {}
|
|
1604
|
-
}, [columnOrder, columnVisibility, sorting, filterValues, searchValue, advancedFilter])
|
|
1720
|
+
}, [columnOrder, columnVisibility, columnSizing, sorting, filterValues, searchValue, advancedFilter])
|
|
1605
1721
|
|
|
1606
1722
|
const applyPerspectiveSettings = React.useCallback((
|
|
1607
1723
|
settings: PerspectiveSettings,
|
|
@@ -1636,6 +1752,13 @@ export function DataTable<T>({
|
|
|
1636
1752
|
setSorting([])
|
|
1637
1753
|
onSortingChange?.([])
|
|
1638
1754
|
}
|
|
1755
|
+
if (normalized.columnSizing) {
|
|
1756
|
+
setColumnSizing(normalized.columnSizing)
|
|
1757
|
+
columnSizingRef.current = normalized.columnSizing
|
|
1758
|
+
} else {
|
|
1759
|
+
setColumnSizing({})
|
|
1760
|
+
columnSizingRef.current = {}
|
|
1761
|
+
}
|
|
1639
1762
|
// Two filter shapes can live in `settings.filters`:
|
|
1640
1763
|
// 1. Persisted advanced-filter tree: `{ v: 2, root: {...} }`
|
|
1641
1764
|
// 2. Legacy flat FilterValues record: arbitrary `{ key: value, ... }`
|
|
@@ -1672,6 +1795,17 @@ export function DataTable<T>({
|
|
|
1672
1795
|
const snapshot: PerspectiveSnapshot = { perspectiveId: nextId, settings: normalized, updatedAt: Date.now() }
|
|
1673
1796
|
writePerspectiveSnapshot(perspectiveTableId, snapshot)
|
|
1674
1797
|
initialSnapshotRef.current = snapshot
|
|
1798
|
+
} else if (normalized.columnSizing && Object.keys(normalized.columnSizing).length) {
|
|
1799
|
+
// Preserve a "No view" snapshot that only carries user column widths so
|
|
1800
|
+
// they survive refresh without an active perspective (#1835). A genuine
|
|
1801
|
+
// "No view" clear passes empty settings (no columnSizing) and still clears.
|
|
1802
|
+
const snapshot: PerspectiveSnapshot = {
|
|
1803
|
+
perspectiveId: null,
|
|
1804
|
+
settings: { columnSizing: normalized.columnSizing },
|
|
1805
|
+
updatedAt: Date.now(),
|
|
1806
|
+
}
|
|
1807
|
+
writePerspectiveSnapshot(perspectiveTableId, snapshot)
|
|
1808
|
+
initialSnapshotRef.current = snapshot
|
|
1675
1809
|
} else {
|
|
1676
1810
|
writePerspectiveSnapshot(perspectiveTableId, null)
|
|
1677
1811
|
initialSnapshotRef.current = null
|
|
@@ -1679,6 +1813,46 @@ export function DataTable<T>({
|
|
|
1679
1813
|
}
|
|
1680
1814
|
}, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter])
|
|
1681
1815
|
|
|
1816
|
+
// Persist the current column widths into the local snapshot so they survive a
|
|
1817
|
+
// refresh even without saving a perspective (#1835). Widths are merged into the
|
|
1818
|
+
// active perspective's other settings — resizing never disturbs the saved
|
|
1819
|
+
// column order/visibility/filters.
|
|
1820
|
+
const persistColumnSizingSnapshot = React.useCallback(() => {
|
|
1821
|
+
if (!perspectiveTableId) return
|
|
1822
|
+
const sizing = columnSizingRef.current
|
|
1823
|
+
const existing = readPerspectiveSnapshot(perspectiveTableId) ?? initialSnapshotRef.current
|
|
1824
|
+
const baseSettings: PerspectiveSettings = existing?.settings
|
|
1825
|
+
? { ...existing.settings }
|
|
1826
|
+
: (mergedInitialSettings ? { ...mergedInitialSettings } : {})
|
|
1827
|
+
if (sizing && Object.keys(sizing).length) baseSettings.columnSizing = sizing
|
|
1828
|
+
else delete baseSettings.columnSizing
|
|
1829
|
+
const snapshot: PerspectiveSnapshot = {
|
|
1830
|
+
perspectiveId: existing?.perspectiveId ?? activePerspectiveId ?? null,
|
|
1831
|
+
settings: baseSettings,
|
|
1832
|
+
updatedAt: Date.now(),
|
|
1833
|
+
}
|
|
1834
|
+
writePerspectiveSnapshot(perspectiveTableId, snapshot)
|
|
1835
|
+
initialSnapshotRef.current = snapshot
|
|
1836
|
+
}, [perspectiveTableId, activePerspectiveId, mergedInitialSettings])
|
|
1837
|
+
|
|
1838
|
+
const handleColumnResize = React.useCallback((colId: string, width: number) => {
|
|
1839
|
+
const next = { ...columnSizingRef.current, [colId]: width }
|
|
1840
|
+
columnSizingRef.current = next
|
|
1841
|
+
setColumnSizing(next)
|
|
1842
|
+
}, [])
|
|
1843
|
+
|
|
1844
|
+
const commitColumnSizing = React.useCallback(() => {
|
|
1845
|
+
persistColumnSizingSnapshot()
|
|
1846
|
+
}, [persistColumnSizingSnapshot])
|
|
1847
|
+
|
|
1848
|
+
const resetColumnSize = React.useCallback((colId: string) => {
|
|
1849
|
+
const next = { ...columnSizingRef.current }
|
|
1850
|
+
delete next[colId]
|
|
1851
|
+
columnSizingRef.current = next
|
|
1852
|
+
setColumnSizing(next)
|
|
1853
|
+
persistColumnSizingSnapshot()
|
|
1854
|
+
}, [persistColumnSizingSnapshot])
|
|
1855
|
+
|
|
1682
1856
|
React.useLayoutEffect(() => {
|
|
1683
1857
|
if (!perspectiveTableId) return
|
|
1684
1858
|
if (snapshotHydratedTableRef.current === perspectiveTableId) return
|
|
@@ -2012,6 +2186,10 @@ export function DataTable<T>({
|
|
|
2012
2186
|
|
|
2013
2187
|
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
|
2014
2188
|
const enableHeaderDnd = Boolean(columnChooser)
|
|
2189
|
+
// Column resize is offered only where widths can persist (#1835): tables with a
|
|
2190
|
+
// perspective config. Portal / settings / sub-tables that opt out of perspectives
|
|
2191
|
+
// get no handle, so resizing never silently resets on reload for them.
|
|
2192
|
+
const enableColumnResize = perspectiveEnabled
|
|
2015
2193
|
const stableDndContextId = React.useMemo(
|
|
2016
2194
|
() => sanitizeDndContextId(
|
|
2017
2195
|
extensionTableId
|
|
@@ -2845,13 +3023,30 @@ export function DataTable<T>({
|
|
|
2845
3023
|
) : null}
|
|
2846
3024
|
</Button>
|
|
2847
3025
|
)
|
|
3026
|
+
const columnId = header.column.id
|
|
3027
|
+
const sizedWidth = enableColumnResize && columnId ? columnSizing[columnId] : undefined
|
|
3028
|
+
const resizeHandle = enableColumnResize && !header.isPlaceholder && columnId ? (
|
|
3029
|
+
<ColumnResizeHandle
|
|
3030
|
+
columnId={columnId}
|
|
3031
|
+
onResize={handleColumnResize}
|
|
3032
|
+
onCommit={commitColumnSizing}
|
|
3033
|
+
onReset={resetColumnSize}
|
|
3034
|
+
ariaLabel={t('ui.dataTable.resizeColumn', 'Resize column')}
|
|
3035
|
+
/>
|
|
3036
|
+
) : null
|
|
2848
3037
|
return enableHeaderDnd ? (
|
|
2849
|
-
<SortableHeaderCell key={header.id} id={header.id} className={responsiveClass(priority, columnMeta?.hidden) + stickyClass}>
|
|
3038
|
+
<SortableHeaderCell key={header.id} id={header.id} width={sizedWidth} className={cn('group', responsiveClass(priority, columnMeta?.hidden) + stickyClass)}>
|
|
2850
3039
|
{headerCellContent}
|
|
3040
|
+
{resizeHandle}
|
|
2851
3041
|
</SortableHeaderCell>
|
|
2852
3042
|
) : (
|
|
2853
|
-
<TableHead
|
|
3043
|
+
<TableHead
|
|
3044
|
+
key={header.id}
|
|
3045
|
+
className={cn('group relative', responsiveClass(priority, columnMeta?.hidden) + stickyClass)}
|
|
3046
|
+
style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
|
|
3047
|
+
>
|
|
2854
3048
|
{headerCellContent}
|
|
3049
|
+
{resizeHandle}
|
|
2855
3050
|
</TableHead>
|
|
2856
3051
|
)
|
|
2857
3052
|
})}
|
|
@@ -2974,14 +3169,22 @@ export function DataTable<T>({
|
|
|
2974
3169
|
tooltipText = cellValue != null ? String(cellValue) : undefined
|
|
2975
3170
|
}
|
|
2976
3171
|
|
|
3172
|
+
// A user-resized width (#1835) overrides the default truncation
|
|
3173
|
+
// max-width so the cell truncates at the dragged column width.
|
|
3174
|
+
const sizedWidth = enableColumnResize && columnId ? columnSizing[columnId] : undefined
|
|
3175
|
+
const effectiveMaxWidth = typeof sizedWidth === 'number' ? `${sizedWidth}px` : maxWidth
|
|
2977
3176
|
const wrappedContent = shouldTruncate ? (
|
|
2978
|
-
<TruncatedCell maxWidth={
|
|
3177
|
+
<TruncatedCell maxWidth={effectiveMaxWidth} tooltipContent={tooltipText}>
|
|
2979
3178
|
{content}
|
|
2980
3179
|
</TruncatedCell>
|
|
2981
3180
|
) : content
|
|
2982
3181
|
|
|
2983
3182
|
return (
|
|
2984
|
-
<TableCell
|
|
3183
|
+
<TableCell
|
|
3184
|
+
key={cell.id}
|
|
3185
|
+
className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}
|
|
3186
|
+
style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
|
|
3187
|
+
>
|
|
2985
3188
|
{wrappedContent}
|
|
2986
3189
|
</TableCell>
|
|
2987
3190
|
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { sanitizePerspectiveSettings } from '../DataTable'
|
|
3
|
+
|
|
4
|
+
// Column widths persisted in PerspectiveSettings come from untrusted sources
|
|
5
|
+
// (localStorage snapshot, saved perspectives API), so `sanitizePerspectiveSettings`
|
|
6
|
+
// must validate and clamp them before they drive layout (#1835).
|
|
7
|
+
describe('sanitizePerspectiveSettings — columnSizing (#1835)', () => {
|
|
8
|
+
it('keeps valid integer widths keyed by column id', () => {
|
|
9
|
+
const result = sanitizePerspectiveSettings({ columnSizing: { name: 240, status: 120 } })
|
|
10
|
+
expect(result?.columnSizing).toEqual({ name: 240, status: 120 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('clamps widths into the [60, 900] bound and rounds fractional pixels', () => {
|
|
14
|
+
const result = sanitizePerspectiveSettings({
|
|
15
|
+
columnSizing: { tooSmall: 10, tooBig: 5000, fractional: 199.7 },
|
|
16
|
+
})
|
|
17
|
+
expect(result?.columnSizing).toEqual({ tooSmall: 60, tooBig: 900, fractional: 200 })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('drops non-numeric, non-finite, and prototype-polluting entries', () => {
|
|
21
|
+
// JSON.parse creates genuine own `__proto__` / `constructor` keys (unlike an
|
|
22
|
+
// object literal, where `__proto__` sets the prototype), so the forbidden-key
|
|
23
|
+
// guard is actually exercised.
|
|
24
|
+
const columnSizing = JSON.parse('{"good":150,"__proto__":300,"constructor":400}')
|
|
25
|
+
columnSizing.nan = Number.NaN
|
|
26
|
+
columnSizing.inf = Number.POSITIVE_INFINITY
|
|
27
|
+
columnSizing.str = '200'
|
|
28
|
+
const result = sanitizePerspectiveSettings({ columnSizing })
|
|
29
|
+
expect(result?.columnSizing).toEqual({ good: 150 })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('omits columnSizing entirely when no valid entry survives', () => {
|
|
33
|
+
const result = sanitizePerspectiveSettings({ columnSizing: { bad: Number.NaN } })
|
|
34
|
+
expect(result?.columnSizing).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('preserves columnSizing alongside the other settings', () => {
|
|
38
|
+
const result = sanitizePerspectiveSettings({
|
|
39
|
+
columnOrder: ['a', 'b'],
|
|
40
|
+
columnVisibility: { a: false },
|
|
41
|
+
columnSizing: { a: 300 },
|
|
42
|
+
})
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
columnOrder: ['a', 'b'],
|
|
45
|
+
columnVisibility: { a: false },
|
|
46
|
+
columnSizing: { a: 300 },
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -17,6 +17,16 @@ describe('DataTable perspective snapshot storage (versioned envelope)', () => {
|
|
|
17
17
|
expect(readPerspectiveSnapshot('tbl')).toEqual(snapshot)
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
+
it('round-trips user column widths (columnSizing) through the snapshot (#1835)', () => {
|
|
21
|
+
const snapshot = {
|
|
22
|
+
perspectiveId: 'p1',
|
|
23
|
+
settings: { columnOrder: ['a', 'b'], columnSizing: { a: 240, b: 120 } },
|
|
24
|
+
updatedAt: 456,
|
|
25
|
+
}
|
|
26
|
+
writePerspectiveSnapshot('tbl-sizing', snapshot)
|
|
27
|
+
expect(readPerspectiveSnapshot('tbl-sizing')).toEqual(snapshot)
|
|
28
|
+
})
|
|
29
|
+
|
|
20
30
|
it('migrates a legacy bare (pre-envelope) snapshot on read', () => {
|
|
21
31
|
const legacy = { perspectiveId: 'p2', settings: { columnOrder: ['x'] }, updatedAt: 7 }
|
|
22
32
|
localStorage.setItem(`${PREFIX}:tbl2`, JSON.stringify(legacy))
|