@startsimpli/ui 0.4.113 → 0.4.114
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 +5 -5
- package/src/components/unified-table/UnifiedTable.tsx +49 -32
- package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx +234 -0
- package/src/components/unified-table/__tests__/utils/column-visibility.test.ts +97 -0
- package/src/components/unified-table/utils/columnVisibility.ts +105 -0
- package/src/components/unified-table/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.114",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
48
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
+
"@startsimpli/api": "^0.5.58",
|
|
50
51
|
"@startsimpli/auth": "^0.4.35",
|
|
51
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
52
|
-
"@startsimpli/api": "^0.5.58"
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"tailwindcss": "^3.4.19",
|
|
124
124
|
"tsup": "^8.5.1",
|
|
125
125
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/i18n": "0.1.3",
|
|
127
|
+
"@startsimpli/realtime": "0.0.2"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -11,6 +11,7 @@ import { TablePagination } from './components/TablePagination'
|
|
|
11
11
|
import { StandardTableToolbar } from './components/Toolbar'
|
|
12
12
|
import { Loader2 } from 'lucide-react'
|
|
13
13
|
import { cn } from '../../utils'
|
|
14
|
+
import { reconcileColumnVisibility, readStoredColumnVisibility, isForcedVisible } from './utils/columnVisibility'
|
|
14
15
|
|
|
15
16
|
export function UnifiedTable<TData>({
|
|
16
17
|
data,
|
|
@@ -56,31 +57,38 @@ export function UnifiedTable<TData>({
|
|
|
56
57
|
return urlState.getURLState()
|
|
57
58
|
}, []) // Only run once on mount
|
|
58
59
|
|
|
59
|
-
// Column visibility state
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
60
|
+
// Column visibility state.
|
|
61
|
+
//
|
|
62
|
+
// A stored map is never trusted verbatim: it is reconciled against the
|
|
63
|
+
// columns actually on screen, so an attribute declared AFTER the user last
|
|
64
|
+
// saved a choice takes `defaultVisible` instead of being pinned visible for
|
|
65
|
+
// that one browser (bd startsim-7r2ta). See utils/columnVisibility.ts for the
|
|
66
|
+
// three rules — absent takes the default, a stored `false` survives,
|
|
67
|
+
// `alwaysVisible` outranks both.
|
|
68
|
+
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() =>
|
|
69
|
+
reconcileColumnVisibility(
|
|
70
|
+
columns,
|
|
71
|
+
readStoredColumnVisibility(columnVisibilityConfig?.persistKey),
|
|
72
|
+
columnVisibilityConfig
|
|
73
|
+
)
|
|
74
|
+
)
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
// Callers that render before their schema lands (a runtime-declared entity
|
|
77
|
+
// type, a lazily loaded column set) hand us a short `columns` array for a
|
|
78
|
+
// render or two, so the reconciliation has to hold for the columns that
|
|
79
|
+
// arrive later as well. Reading through this memo rather than off state means
|
|
80
|
+
// a late column is never visible for a frame before being corrected.
|
|
81
|
+
const resolvedColumnVisibility = useMemo(
|
|
82
|
+
() => reconcileColumnVisibility(columns, columnVisibility, columnVisibilityConfig),
|
|
83
|
+
[columns, columnVisibility, columnVisibilityConfig]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
// Identity-stable when nothing changed, so this settles in one pass.
|
|
88
|
+
if (resolvedColumnVisibility !== columnVisibility) {
|
|
89
|
+
setColumnVisibility(resolvedColumnVisibility)
|
|
90
|
+
}
|
|
91
|
+
}, [resolvedColumnVisibility, columnVisibility])
|
|
84
92
|
|
|
85
93
|
// Client-side sorting state (used when no external sorting config is provided)
|
|
86
94
|
const [localSort, setLocalSort] = useState<SortState>(() => {
|
|
@@ -100,13 +108,13 @@ export function UnifiedTable<TData>({
|
|
|
100
108
|
try {
|
|
101
109
|
localStorage.setItem(
|
|
102
110
|
`table-columns-${columnVisibilityConfig.persistKey}`,
|
|
103
|
-
JSON.stringify(
|
|
111
|
+
JSON.stringify(resolvedColumnVisibility)
|
|
104
112
|
)
|
|
105
113
|
} catch {
|
|
106
114
|
// Ignore localStorage errors
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
|
-
}, [
|
|
117
|
+
}, [resolvedColumnVisibility, columnVisibilityConfig?.persistKey])
|
|
110
118
|
|
|
111
119
|
// Toggle column visibility
|
|
112
120
|
const toggleColumnVisibility = useCallback((columnId: string) => {
|
|
@@ -114,9 +122,12 @@ export function UnifiedTable<TData>({
|
|
|
114
122
|
if (columnVisibilityConfig?.alwaysVisible?.includes(columnId)) {
|
|
115
123
|
return
|
|
116
124
|
}
|
|
125
|
+
// Anything but an explicit `false` is on screen, so that's what a toggle
|
|
126
|
+
// has to turn off — `!prev[id]` would SHOW a column whose id the map has
|
|
127
|
+
// yet to learn, which is the very state this component used to render.
|
|
117
128
|
setColumnVisibility(prev => ({
|
|
118
129
|
...prev,
|
|
119
|
-
[columnId]:
|
|
130
|
+
[columnId]: prev[columnId] === false
|
|
120
131
|
}))
|
|
121
132
|
}, [columnVisibilityConfig?.alwaysVisible])
|
|
122
133
|
|
|
@@ -125,8 +136,14 @@ export function UnifiedTable<TData>({
|
|
|
125
136
|
if (!columnVisibilityConfig?.enabled) {
|
|
126
137
|
return columns
|
|
127
138
|
}
|
|
128
|
-
return columns.filter(col =>
|
|
129
|
-
|
|
139
|
+
return columns.filter(col =>
|
|
140
|
+
// "Cannot be hidden" is a promise to the caller, not a default: a stored
|
|
141
|
+
// map written before the column joined that list must not be able to
|
|
142
|
+
// drop it, and the picker won't offer it back.
|
|
143
|
+
isForcedVisible(col, columnVisibilityConfig.alwaysVisible) ||
|
|
144
|
+
resolvedColumnVisibility[col.id] !== false
|
|
145
|
+
)
|
|
146
|
+
}, [columns, resolvedColumnVisibility, columnVisibilityConfig?.enabled, columnVisibilityConfig?.alwaysVisible])
|
|
130
147
|
|
|
131
148
|
// Get hideable columns for the dropdown
|
|
132
149
|
const hideableColumns = useMemo(() => {
|
|
@@ -263,7 +280,7 @@ export function UnifiedTable<TData>({
|
|
|
263
280
|
// Get current view state for saving
|
|
264
281
|
const getCurrentViewState = useCallback((): Omit<SavedView, 'id' | 'name' | 'createdAt'> => {
|
|
265
282
|
return {
|
|
266
|
-
columnVisibility: columnVisibilityConfig?.enabled ?
|
|
283
|
+
columnVisibility: columnVisibilityConfig?.enabled ? resolvedColumnVisibility : undefined,
|
|
267
284
|
columnOrder: columnReorderConfig?.enabled ? columnReorder.columnOrder : undefined,
|
|
268
285
|
sortBy: currentSortBy,
|
|
269
286
|
sortDirection: currentSortDirection,
|
|
@@ -271,7 +288,7 @@ export function UnifiedTable<TData>({
|
|
|
271
288
|
pageSize: paginationConfig?.pageSize,
|
|
272
289
|
}
|
|
273
290
|
}, [
|
|
274
|
-
|
|
291
|
+
resolvedColumnVisibility,
|
|
275
292
|
columnVisibilityConfig?.enabled,
|
|
276
293
|
columnReorder.columnOrder,
|
|
277
294
|
columnReorderConfig?.enabled,
|
|
@@ -450,7 +467,7 @@ export function UnifiedTable<TData>({
|
|
|
450
467
|
searchDebounceMs={searchConfig?.debounceMs}
|
|
451
468
|
columnVisibilityEnabled={columnVisibilityConfig?.enabled}
|
|
452
469
|
hideableColumns={hideableColumns}
|
|
453
|
-
columnVisibility={
|
|
470
|
+
columnVisibility={resolvedColumnVisibility}
|
|
454
471
|
onToggleColumnVisibility={toggleColumnVisibility}
|
|
455
472
|
exportEnabled={exportConfig?.enabled}
|
|
456
473
|
exportData={sortedData}
|
package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A saved column map must not force-show columns declared after it (bd startsim-7r2ta).
|
|
3
|
+
*
|
|
4
|
+
* UnifiedTable seeds `columnVisibility` from localStorage and then keeps a
|
|
5
|
+
* column whose id is missing from that map, because the filter asks
|
|
6
|
+
* `map[id] !== false` and `undefined !== false`. On a runtime-declared schema —
|
|
7
|
+
* every foundry tenant — that means every attribute added AFTER a reviewer last
|
|
8
|
+
* touched the column picker is pinned visible for that reviewer alone, and
|
|
9
|
+
* invisible to anyone checking in a clean browser.
|
|
10
|
+
*
|
|
11
|
+
* The reconciliation has to thread three needles at once:
|
|
12
|
+
* 1. an id ABSENT from the stored map falls back to `defaultVisible`,
|
|
13
|
+
* 2. a stored `false` is a deliberate hide and survives,
|
|
14
|
+
* 3. `alwaysVisible` outranks both.
|
|
15
|
+
*/
|
|
16
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
17
|
+
import { useState } from 'react'
|
|
18
|
+
import { UnifiedTable } from '../../index'
|
|
19
|
+
import { ColumnConfig } from '../../types'
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rows: Row[] = [{ id: '1', name: 'Alpha' }]
|
|
27
|
+
|
|
28
|
+
const NAME: ColumnConfig<Row> = { id: 'name', header: 'Name', accessorKey: 'name' }
|
|
29
|
+
const STATUS: ColumnConfig<Row> = { id: 'status', header: 'Status', accessorKey: 'status' }
|
|
30
|
+
const SCOPE: ColumnConfig<Row> = { id: 'scope_path', header: 'Scope path', accessorKey: 'scope_path' }
|
|
31
|
+
const ACTIONS: ColumnConfig<Row> = { id: '__actions', header: 'Actions' }
|
|
32
|
+
|
|
33
|
+
const PERSIST_KEY = 'records-topic'
|
|
34
|
+
const STORAGE_KEY = `table-columns-${PERSIST_KEY}`
|
|
35
|
+
|
|
36
|
+
function seedStoredMap(map: Record<string, boolean>) {
|
|
37
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readStoredMap(): Record<string, boolean> {
|
|
41
|
+
return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Only the headers of the rendered grid — the column picker lists them too. */
|
|
45
|
+
function headers(): string[] {
|
|
46
|
+
return within(screen.getByRole('table'))
|
|
47
|
+
.getAllByRole('columnheader')
|
|
48
|
+
.map((th) => th.textContent?.trim() ?? '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
window.localStorage.clear()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('a stored column map is reconciled against the current columns', () => {
|
|
56
|
+
it('hides a column the stored map never heard of when defaults exclude it', () => {
|
|
57
|
+
// The reviewer saved a choice back when the type had name + status. Since
|
|
58
|
+
// then the schema grew a scope_path attribute, which the app does NOT
|
|
59
|
+
// default on.
|
|
60
|
+
seedStoredMap({ name: true, status: true })
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<UnifiedTable<Row>
|
|
64
|
+
data={rows}
|
|
65
|
+
columns={[NAME, STATUS, SCOPE]}
|
|
66
|
+
tableId="records"
|
|
67
|
+
getRowId={(r) => r.id}
|
|
68
|
+
columnVisibility={{
|
|
69
|
+
enabled: true,
|
|
70
|
+
alwaysVisible: ['name'],
|
|
71
|
+
defaultVisible: ['name', 'status'],
|
|
72
|
+
persistKey: PERSIST_KEY,
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(headers()).toEqual(['Name', 'Status'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('still shows an unheard-of column when the caller defaults everything on', () => {
|
|
81
|
+
// No defaultVisible => "all visible" is the documented default, so an
|
|
82
|
+
// absent id must stay visible. The fix must not turn absence into a hide.
|
|
83
|
+
seedStoredMap({ name: true })
|
|
84
|
+
|
|
85
|
+
render(
|
|
86
|
+
<UnifiedTable<Row>
|
|
87
|
+
data={rows}
|
|
88
|
+
columns={[NAME, STATUS, SCOPE]}
|
|
89
|
+
tableId="records"
|
|
90
|
+
getRowId={(r) => r.id}
|
|
91
|
+
columnVisibility={{ enabled: true, persistKey: PERSIST_KEY }}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('keeps a deliberate hide the user saved', () => {
|
|
99
|
+
// status is in defaultVisible, but the stored map says false. Persistence
|
|
100
|
+
// exists precisely so that choice outlives the default.
|
|
101
|
+
seedStoredMap({ name: true, status: false })
|
|
102
|
+
|
|
103
|
+
render(
|
|
104
|
+
<UnifiedTable<Row>
|
|
105
|
+
data={rows}
|
|
106
|
+
columns={[NAME, STATUS, SCOPE]}
|
|
107
|
+
tableId="records"
|
|
108
|
+
getRowId={(r) => r.id}
|
|
109
|
+
columnVisibility={{
|
|
110
|
+
enabled: true,
|
|
111
|
+
defaultVisible: ['name', 'status'],
|
|
112
|
+
persistKey: PERSIST_KEY,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(headers()).toEqual(['Name'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('overrides a stored hide of an always-visible column', () => {
|
|
121
|
+
// The caller added __actions to alwaysVisible after this user had hidden
|
|
122
|
+
// it (or the column was renamed into the always-visible set). An
|
|
123
|
+
// always-visible column that the table drops takes the row actions with it.
|
|
124
|
+
seedStoredMap({ name: false, status: true, __actions: false })
|
|
125
|
+
|
|
126
|
+
render(
|
|
127
|
+
<UnifiedTable<Row>
|
|
128
|
+
data={rows}
|
|
129
|
+
columns={[NAME, STATUS, ACTIONS]}
|
|
130
|
+
tableId="records"
|
|
131
|
+
getRowId={(r) => r.id}
|
|
132
|
+
columnVisibility={{
|
|
133
|
+
enabled: true,
|
|
134
|
+
alwaysVisible: ['name', '__actions'],
|
|
135
|
+
defaultVisible: ['name', 'status'],
|
|
136
|
+
persistKey: PERSIST_KEY,
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(headers()).toEqual(['Name', 'Status', 'Actions'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('shows an always-visible column that defaultVisible forgot', () => {
|
|
145
|
+
// Nothing stored at all: the seed path must not let a defaultVisible list
|
|
146
|
+
// that omits an always-visible id hide it.
|
|
147
|
+
render(
|
|
148
|
+
<UnifiedTable<Row>
|
|
149
|
+
data={rows}
|
|
150
|
+
columns={[NAME, STATUS, ACTIONS]}
|
|
151
|
+
tableId="records"
|
|
152
|
+
getRowId={(r) => r.id}
|
|
153
|
+
columnVisibility={{
|
|
154
|
+
enabled: true,
|
|
155
|
+
alwaysVisible: ['name', '__actions'],
|
|
156
|
+
defaultVisible: ['name', 'status'],
|
|
157
|
+
persistKey: PERSIST_KEY,
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
expect(headers()).toEqual(['Name', 'Status', 'Actions'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('keeps a hideable:false column that the defaults leave out', () => {
|
|
166
|
+
// `hideable: false` is already excluded from the column picker, so a
|
|
167
|
+
// reconciliation that hid one would strand it: off the table and off the
|
|
168
|
+
// menu that puts it back.
|
|
169
|
+
const pinned: ColumnConfig<Row> = { ...SCOPE, hideable: false }
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<UnifiedTable<Row>
|
|
173
|
+
data={rows}
|
|
174
|
+
columns={[NAME, STATUS, pinned]}
|
|
175
|
+
tableId="records"
|
|
176
|
+
getRowId={(r) => r.id}
|
|
177
|
+
columnVisibility={{
|
|
178
|
+
enabled: true,
|
|
179
|
+
defaultVisible: ['name', 'status'],
|
|
180
|
+
persistKey: PERSIST_KEY,
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('columns that arrive after mount', () => {
|
|
190
|
+
function LateSchema() {
|
|
191
|
+
const [cols, setCols] = useState<ColumnConfig<Row>[]>([NAME])
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<button onClick={() => setCols([NAME, STATUS, SCOPE])}>load schema</button>
|
|
195
|
+
<UnifiedTable<Row>
|
|
196
|
+
data={rows}
|
|
197
|
+
columns={cols}
|
|
198
|
+
tableId="records"
|
|
199
|
+
getRowId={(r) => r.id}
|
|
200
|
+
columnVisibility={{
|
|
201
|
+
enabled: true,
|
|
202
|
+
alwaysVisible: ['name'],
|
|
203
|
+
defaultVisible: ['name', 'status'],
|
|
204
|
+
persistKey: PERSIST_KEY,
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
</>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
it('reconciles a column the table had not been handed at mount', () => {
|
|
212
|
+
seedStoredMap({ name: true, status: true })
|
|
213
|
+
render(<LateSchema />)
|
|
214
|
+
|
|
215
|
+
fireEvent.click(screen.getByText('load schema'))
|
|
216
|
+
|
|
217
|
+
expect(headers()).toEqual(['Name', 'Status'])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('does not wipe a saved hide for a column that has not arrived yet', () => {
|
|
221
|
+
// Reconciling by rebuilding the map from `columns` alone would drop
|
|
222
|
+
// status:false while the schema is still in flight, and the persist effect
|
|
223
|
+
// would write that loss straight back to localStorage.
|
|
224
|
+
seedStoredMap({ name: true, status: false })
|
|
225
|
+
render(<LateSchema />)
|
|
226
|
+
|
|
227
|
+
expect(readStoredMap().status).toBe(false)
|
|
228
|
+
|
|
229
|
+
fireEvent.click(screen.getByText('load schema'))
|
|
230
|
+
|
|
231
|
+
expect(readStoredMap().status).toBe(false)
|
|
232
|
+
expect(headers()).toEqual(['Name'])
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit cover for the reconciliation UnifiedTable applies to a stored column
|
|
3
|
+
* map (bd startsim-7r2ta). The integration test pins the rendered result; this
|
|
4
|
+
* pins the rules themselves, including the cases a rendered table can't easily
|
|
5
|
+
* show — an id the current schema no longer declares, a corrupt stored value.
|
|
6
|
+
*/
|
|
7
|
+
import { reconcileColumnVisibility } from '../../utils/columnVisibility'
|
|
8
|
+
|
|
9
|
+
const cols = [{ id: 'name' }, { id: 'status' }, { id: 'scope_path' }]
|
|
10
|
+
|
|
11
|
+
describe('reconcileColumnVisibility', () => {
|
|
12
|
+
it('falls an unknown id back to defaultVisible, not to visible', () => {
|
|
13
|
+
expect(
|
|
14
|
+
reconcileColumnVisibility(cols, { name: true, status: true }, {
|
|
15
|
+
enabled: true,
|
|
16
|
+
defaultVisible: ['name', 'status'],
|
|
17
|
+
})
|
|
18
|
+
).toEqual({ name: true, status: true, scope_path: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('treats "no defaultVisible" as all-visible for unknown ids', () => {
|
|
22
|
+
expect(
|
|
23
|
+
reconcileColumnVisibility(cols, { name: true }, { enabled: true })
|
|
24
|
+
).toEqual({ name: true, status: true, scope_path: true })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('preserves a stored false even when the defaults say otherwise', () => {
|
|
28
|
+
expect(
|
|
29
|
+
reconcileColumnVisibility(cols, { status: false }, {
|
|
30
|
+
enabled: true,
|
|
31
|
+
defaultVisible: ['name', 'status', 'scope_path'],
|
|
32
|
+
})
|
|
33
|
+
).toEqual({ name: true, status: false, scope_path: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('forces an always-visible column on over a stored hide', () => {
|
|
37
|
+
expect(
|
|
38
|
+
reconcileColumnVisibility(cols, { name: false, status: false }, {
|
|
39
|
+
enabled: true,
|
|
40
|
+
alwaysVisible: ['name'],
|
|
41
|
+
defaultVisible: ['name'],
|
|
42
|
+
})
|
|
43
|
+
).toEqual({ name: true, status: false, scope_path: false })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('forces an always-visible column on when defaultVisible omits it', () => {
|
|
47
|
+
expect(
|
|
48
|
+
reconcileColumnVisibility([...cols, { id: '__actions' }], null, {
|
|
49
|
+
enabled: true,
|
|
50
|
+
alwaysVisible: ['__actions'],
|
|
51
|
+
defaultVisible: ['name'],
|
|
52
|
+
})
|
|
53
|
+
).toEqual({ name: true, status: false, scope_path: false, __actions: true })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps a stored choice for a column the current schema has not declared', () => {
|
|
57
|
+
// Mid-flight schemas hand the table a short `columns` array. Rebuilding the
|
|
58
|
+
// map from `columns` alone would drop the hide, and the persist effect
|
|
59
|
+
// would write that loss back to localStorage.
|
|
60
|
+
expect(
|
|
61
|
+
reconcileColumnVisibility([{ id: 'name' }], { name: true, status: false }, {
|
|
62
|
+
enabled: true,
|
|
63
|
+
defaultVisible: ['name', 'status'],
|
|
64
|
+
})
|
|
65
|
+
).toEqual({ name: true, status: false })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('ignores non-boolean values in a corrupt stored map', () => {
|
|
69
|
+
expect(
|
|
70
|
+
reconcileColumnVisibility(cols, { name: 'yes', status: null } as never, {
|
|
71
|
+
enabled: true,
|
|
72
|
+
defaultVisible: ['name'],
|
|
73
|
+
})
|
|
74
|
+
).toEqual({ name: true, status: false, scope_path: false })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns the same object when nothing needs reconciling', () => {
|
|
78
|
+
// The mount-time seed feeds this straight into setState on every columns
|
|
79
|
+
// change; a fresh object every time would loop.
|
|
80
|
+
const stored = { name: true, status: false, scope_path: false }
|
|
81
|
+
expect(
|
|
82
|
+
reconcileColumnVisibility(cols, stored, { enabled: true, defaultVisible: ['name'] })
|
|
83
|
+
).toBe(stored)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('forces a hideable:false column on, since the picker will not offer it back', () => {
|
|
87
|
+
// Same promise as alwaysVisible, and the one the caller is likelier to get
|
|
88
|
+
// wrong: hiding such a column leaves the user no way to restore it.
|
|
89
|
+
expect(
|
|
90
|
+
reconcileColumnVisibility(
|
|
91
|
+
[{ id: 'name' }, { id: 'status' }, { id: 'scope_path', hideable: false }],
|
|
92
|
+
{ name: true, scope_path: false },
|
|
93
|
+
{ enabled: true, defaultVisible: ['name'] }
|
|
94
|
+
)
|
|
95
|
+
).toEqual({ name: true, status: false, scope_path: true })
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ColumnVisibilityConfig, ColumnVisibilityState } from '../types'
|
|
2
|
+
|
|
3
|
+
/** The two fields of a column this reconciliation reads. */
|
|
4
|
+
export interface ReconcilableColumn {
|
|
5
|
+
id: string
|
|
6
|
+
/** `false` is the same promise as listing the id in `alwaysVisible`. */
|
|
7
|
+
hideable?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Column visibility persists as a map of column id -> boolean, and the table
|
|
12
|
+
* treats a MISSING id as visible (`map[id] !== false`). That is the right
|
|
13
|
+
* reading for a fresh browser, and the wrong one for a stored map: a schema
|
|
14
|
+
* declared at runtime — every foundry tenant — grows attributes after a
|
|
15
|
+
* reviewer last saved a column choice, and each new one lands outside the
|
|
16
|
+
* stored map and is therefore pinned on for that reviewer alone, invisible to
|
|
17
|
+
* anyone checking in a clean session. Bumping the persist key clears it once
|
|
18
|
+
* and the next attribute brings it back (bd startsim-7r2ta).
|
|
19
|
+
*
|
|
20
|
+
* So a stored map is reconciled against the columns actually on screen:
|
|
21
|
+
*
|
|
22
|
+
* - an id the stored map has never heard of takes `defaultVisible`
|
|
23
|
+
* (or stays visible when the caller declared no defaults at all),
|
|
24
|
+
* - a stored `false` is a deliberate hide and is left alone — that is the
|
|
25
|
+
* entire point of persisting,
|
|
26
|
+
* - `alwaysVisible` outranks both, so neither a stale stored `false` nor a
|
|
27
|
+
* `defaultVisible` list that forgot the id can drop the name column or the
|
|
28
|
+
* row-actions column. `hideable: false` carries the same promise and is
|
|
29
|
+
* forced the same way: the column picker already refuses to list such a
|
|
30
|
+
* column, so hiding one would leave the user no way to bring it back.
|
|
31
|
+
*
|
|
32
|
+
* Ids the current `columns` no longer declare are carried through untouched.
|
|
33
|
+
* A caller that renders before its schema arrives hands us a short column list
|
|
34
|
+
* for a render or two, and rebuilding the map from `columns` alone would erase
|
|
35
|
+
* the user's saved hides in that window — and the persist effect would then
|
|
36
|
+
* write the loss back to localStorage.
|
|
37
|
+
*
|
|
38
|
+
* Returns the `stored` object itself when nothing needed changing, so callers
|
|
39
|
+
* can feed the result straight back into `setState` without looping.
|
|
40
|
+
*/
|
|
41
|
+
export function reconcileColumnVisibility(
|
|
42
|
+
columns: ReconcilableColumn[],
|
|
43
|
+
stored: ColumnVisibilityState | null | undefined,
|
|
44
|
+
config?: ColumnVisibilityConfig
|
|
45
|
+
): ColumnVisibilityState {
|
|
46
|
+
const { defaultVisible, alwaysVisible } = config ?? {}
|
|
47
|
+
|
|
48
|
+
const next: ColumnVisibilityState = {}
|
|
49
|
+
let changed = false
|
|
50
|
+
|
|
51
|
+
// Carry the stored choices forward. A non-boolean is a corrupt entry (hand-
|
|
52
|
+
// edited storage, an older shape) and is treated as absent.
|
|
53
|
+
for (const [id, value] of Object.entries(stored ?? {})) {
|
|
54
|
+
if (typeof value === 'boolean') next[id] = value
|
|
55
|
+
else changed = true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const col of columns) {
|
|
59
|
+
const id = col.id
|
|
60
|
+
const desired = isForcedVisible(col, alwaysVisible)
|
|
61
|
+
? true
|
|
62
|
+
: id in next
|
|
63
|
+
? next[id]
|
|
64
|
+
: defaultVisible
|
|
65
|
+
? defaultVisible.includes(id)
|
|
66
|
+
: true
|
|
67
|
+
if (next[id] !== desired) {
|
|
68
|
+
next[id] = desired
|
|
69
|
+
changed = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return stored && !changed ? stored : next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A column the user is not allowed to hide — which therefore must not end up
|
|
78
|
+
* hidden by a stale stored map or by a `defaultVisible` list that omits it.
|
|
79
|
+
*/
|
|
80
|
+
export function isForcedVisible(
|
|
81
|
+
column: ReconcilableColumn,
|
|
82
|
+
alwaysVisible: string[] | undefined
|
|
83
|
+
): boolean {
|
|
84
|
+
return column.hideable === false || !!alwaysVisible?.includes(column.id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The persisted half of the above: read the map UnifiedTable wrote for
|
|
89
|
+
* `persistKey`, tolerating a browser that has no localStorage (SSR, privacy
|
|
90
|
+
* mode) and a value that no longer parses.
|
|
91
|
+
*/
|
|
92
|
+
export function readStoredColumnVisibility(
|
|
93
|
+
persistKey: string | undefined
|
|
94
|
+
): ColumnVisibilityState | null {
|
|
95
|
+
if (!persistKey || typeof window === 'undefined') return null
|
|
96
|
+
try {
|
|
97
|
+
const stored = window.localStorage.getItem(`table-columns-${persistKey}`)
|
|
98
|
+
if (!stored) return null
|
|
99
|
+
const parsed = JSON.parse(stored)
|
|
100
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
101
|
+
return parsed as ColumnVisibilityState
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|