@contember/bindx-dataview 0.1.51 → 0.1.53

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.
@@ -42,6 +42,7 @@ import { useDataViewKey } from './DataViewKeyProvider.js'
42
42
  import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js'
43
43
  import { useDataGridSetup } from './useDataGridSetup.js'
44
44
  import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js'
45
+ import { usePagingTotalCount } from './usePagingTotalCount.js'
45
46
 
46
47
  // ============================================================================
47
48
  // Props
@@ -80,10 +81,18 @@ interface ListState {
80
81
  status: 'loading' | 'error' | 'ready'
81
82
  items: Array<{ id: string; data: object }>
82
83
  totalCount?: number
84
+ /** The query options the items were loaded for. */
85
+ optionsKey?: string
83
86
  }
84
87
 
85
88
  const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] }
86
89
 
90
+ function getRowId(entityType: string, row: Record<string, unknown>): string {
91
+ const id = row['id']
92
+ if (typeof id !== 'string') throw new Error(`${entityType} relation row has no string id`)
93
+ return id
94
+ }
95
+
87
96
  // ============================================================================
88
97
  // Implementation
89
98
  // ============================================================================
@@ -179,19 +188,19 @@ function HasManyDataGridImpl<TEntity extends object>({
179
188
 
180
189
  const relation = extractHasManyRelationRows(result.data, { alias, fieldName })
181
190
  if (!relation) {
182
- setListState({ status: 'ready', items: [] })
191
+ setListState({ status: 'ready', items: [], optionsKey })
183
192
  return
184
193
  }
185
194
 
186
- const items = relation.rows.map((data: Record<string, unknown>) => {
187
- const id = data['id'] as string
188
- dispatcher.dispatch(
189
- setEntityData(targetEntityType, id, data, true),
190
- )
191
- return { id, data: data as object }
195
+ store.indexServerResponse(targetEntityType, relation.rows, setup.selection, schemaRegistry)
196
+ const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data: store.resolveServerOccurrence(data) }))
197
+ store.batchNotifications(() => {
198
+ for (const item of items) {
199
+ dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true))
200
+ }
192
201
  })
193
202
 
194
- setListState({ status: 'ready', items, totalCount: relation.totalCount })
203
+ setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey })
195
204
  } catch (error) {
196
205
  if (abortController.signal.aborted) return
197
206
  setListState({ status: 'error', items: [] })
@@ -203,7 +212,7 @@ function HasManyDataGridImpl<TEntity extends object>({
203
212
  return () => {
204
213
  abortController.abort()
205
214
  }
206
- }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store])
215
+ }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store, schemaRegistry])
207
216
 
208
217
  // ---- Build items from state ----
209
218
  const items = useMemo((): EntityAccessor<TEntity>[] => {
@@ -233,14 +242,16 @@ function HasManyDataGridImpl<TEntity extends object>({
233
242
  }
234
243
  }, [listState.status])
235
244
 
236
- // Update total count from paginateRelation
237
- useEffect(() => {
238
- if (listState.totalCount !== undefined) {
239
- setup.paging.setTotalCount(listState.totalCount)
240
- } else if (listState.status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) {
241
- setup.paging.setTotalCount(setup.paging.queryOffset + itemCount)
242
- }
243
- }, [listState.status, listState.totalCount, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount])
245
+ const countKey = useMemo(
246
+ () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]),
247
+ [parentEntityType, parentEntityId, fieldName, setup.combinedFilter],
248
+ )
249
+ usePagingTotalCount(setup.paging, {
250
+ countedTotal: listState.totalCount ?? null,
251
+ countKey,
252
+ isPageCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey,
253
+ pageItemCount: itemCount,
254
+ })
244
255
 
245
256
  // ---- Reload ----
246
257
  const [, setReloadCounter] = useState(0)
package/src/columnLeaf.ts CHANGED
@@ -23,13 +23,30 @@ export type ColumnType = 'text' | 'number' | 'date' | 'dateTime' | 'boolean' | '
23
23
 
24
24
  export interface ColumnLeafProps {
25
25
  // ── Core ──
26
- readonly name: string
26
+ /**
27
+ * Identity of the column: the key its visibility is stored under and the key
28
+ * its header and cells are rendered by. Optional here because most call sites
29
+ * let it be derived — see {@link resolveColumnNames}. Pass it explicitly when
30
+ * two columns bind the same field, which would otherwise derive one name.
31
+ */
32
+ readonly name?: string
27
33
  readonly fieldName: string | null
28
34
  readonly fieldRef: FieldRef<unknown> | null
29
35
  readonly sortingField: string | null
30
36
  readonly filterName: string | null
31
37
  readonly filterHandler: FilterHandler<FilterArtifact> | undefined
38
+ /**
39
+ * Where the filter starts when nothing is stored for it. Defaults to the
40
+ * handler's (inactive) default artifact.
41
+ */
42
+ readonly filterInitialArtifact?: FilterArtifact
32
43
  readonly isTextSearchable: boolean
44
+ /**
45
+ * The leaf exists only to register its field — for full-text search, or to
46
+ * pull the field into the query. It is not a column: neither the table nor
47
+ * the visibility list shows it.
48
+ */
49
+ readonly virtual?: boolean
33
50
  readonly columnType?: ColumnType
34
51
  readonly collectSelection?: (collectorProxy: unknown) => void
35
52
 
@@ -64,6 +81,44 @@ export function ColumnLeaf(_props: ColumnLeafProps): null {
64
81
  return null
65
82
  }
66
83
 
84
+ // ============================================================================
85
+ // Column Identity
86
+ // ============================================================================
87
+
88
+ /** A column leaf once its identity is settled — what the view exposes. */
89
+ export interface DataViewColumn extends ColumnLeafProps {
90
+ readonly name: string
91
+ }
92
+
93
+ /**
94
+ * Settle each leaf's identity: the declared name, else the bound field, else the
95
+ * position.
96
+ *
97
+ * Position is the last resort rather than the first, because it is only stable
98
+ * while the column order is — and the name is a persisted visibility key, so a
99
+ * reordered grid would read a stale one. That is also why duplicates are
100
+ * reported instead of being suffixed away.
101
+ */
102
+ export function resolveColumnNames(leaves: readonly ColumnLeafProps[]): DataViewColumn[] {
103
+ const columns = leaves.map((leaf, index): DataViewColumn => ({
104
+ ...leaf,
105
+ name: leaf.name ?? leaf.fieldName ?? `col-${index}`,
106
+ }))
107
+
108
+ const duplicates = columns
109
+ .map(it => it.name)
110
+ .filter((name, index, names) => names.indexOf(name) !== index)
111
+ if (duplicates.length > 0) {
112
+ console.warn(
113
+ `[bindx] DataGrid columns share the name ${[...new Set(duplicates)].map(it => `"${it}"`).join(', ')}. ` +
114
+ `A name keys a column's cells and stores its visibility, so the columns act as one. ` +
115
+ `Give each of them a \`name\` prop.`,
116
+ )
117
+ }
118
+
119
+ return columns
120
+ }
121
+
67
122
  // ============================================================================
68
123
  // Generalized Children Analyzer
69
124
  // ============================================================================
package/src/columns.tsx CHANGED
@@ -99,9 +99,15 @@ interface EnumExtraProps<T extends string> {
99
99
  options: readonly T[] | Record<T, ReactNode>
100
100
  }
101
101
 
102
- export interface DataGridEnumColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {}
102
+ export interface DataGridEnumColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {
103
+ /** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
104
+ filterInitialArtifact?: EnumFilterArtifact<NoInfer<T>>
105
+ }
103
106
 
104
- export interface DataGridEnumListColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {}
107
+ export interface DataGridEnumListColumnProps<T extends string> extends DataGridScalarColumnPropsBase<T>, EnumExtraProps<NoInfer<T>> {
108
+ /** Where the column's filter starts when nothing is stored for it. Defaults to no value picked. */
109
+ filterInitialArtifact?: EnumListFilterArtifact<NoInfer<T>>
110
+ }
105
111
 
106
112
  // ============================================================================
107
113
  // Scalar Columns via createColumn()
@@ -186,6 +192,8 @@ export const DataGridHasManyColumn: {
186
192
 
187
193
  export interface DataGridActionColumnProps {
188
194
  children: React.ReactNode | ((entity: EntityAccessor<object>) => React.ReactNode)
195
+ /** Column identity. An action column binds no field, so without this it is named by its position. */
196
+ name?: string
189
197
  header?: React.ReactNode
190
198
  }
191
199
 
@@ -203,7 +211,7 @@ export const DataGridActionColumn = Object.assign(
203
211
 
204
212
  const leafProps: ColumnLeafProps = {
205
213
  columnType: 'action',
206
- name: `action-${Math.random().toString(36).slice(2, 8)}`,
214
+ name: props['name'] as string | undefined,
207
215
  fieldName: null,
208
216
  fieldRef: null,
209
217
  sortingField: null,
@@ -225,10 +233,16 @@ export const DataGridActionColumn = Object.assign(
225
233
 
226
234
  export interface DataGridColumnProps<T> {
227
235
  field?: FieldRef<T>
236
+ /** Column identity. Defaults to the bound field name — pass it when two columns bind one field, or when there is no field. */
237
+ name?: string
228
238
  header?: React.ReactNode
229
239
  sortable?: boolean
230
240
  filter?: boolean
231
241
  filterHandler?: FilterHandler<FilterArtifact>
242
+ /** Where the column's filter starts when nothing is stored for it. Defaults to the handler's inactive artifact. */
243
+ filterInitialArtifact?: FilterArtifact
244
+ /** Register the field without showing a column (e.g. to make it full-text searchable). */
245
+ virtual?: boolean
232
246
  children?: (value: T | null, accessor: EntityAccessor<object>) => React.ReactNode
233
247
  }
234
248
 
@@ -247,7 +261,8 @@ export const DataGridColumn = Object.assign(
247
261
  const children = props['children'] as ((value: unknown, accessor: EntityAccessor<object>) => React.ReactNode) | undefined
248
262
 
249
263
  const leafProps: ColumnLeafProps = {
250
- name: fieldName ?? `col-${Math.random().toString(36).slice(2, 8)}`,
264
+ name: props['name'] as string | undefined,
265
+ virtual: props['virtual'] as boolean | undefined,
251
266
  fieldName,
252
267
  fieldRef: fieldRef ?? null,
253
268
  sortingField: sortable && fieldName ? fieldName : null,
@@ -255,6 +270,7 @@ export const DataGridColumn = Object.assign(
255
270
  filterHandler: filterEnabled && fieldName
256
271
  ? (customHandler ?? textColumnDef.createFilterHandler(fieldName) as FilterHandler<FilterArtifact>)
257
272
  : undefined,
273
+ filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
258
274
  isTextSearchable: false,
259
275
  header,
260
276
  renderCell: (accessor: EntityAccessor<object>) => {
@@ -154,13 +154,22 @@ export function useDataViewElements(): readonly DataViewElementData[] {
154
154
  if (elements) return elements
155
155
  }
156
156
 
157
- // Fallback: derive elements from columns (table layout or no layout markers)
158
- return columns
159
- .filter(c => c.fieldName !== null)
160
- .map(c => ({
161
- name: c.name,
162
- label: c.header,
157
+ // Fallback: derive elements from columns (table layout or no layout markers).
158
+ // Every column is offered — `name` is what the table hides by, so a column
159
+ // with no field of its own is reachable just the same. An empty header is
160
+ // left unset so the label falls back to the formatted name.
161
+ //
162
+ // Columns sharing a name share one visibility flag, so they are listed once
163
+ // rather than as rows that look independent and are not.
164
+ const byName = new Map<string, DataViewElementData>()
165
+ for (const column of columns) {
166
+ if (column.virtual || byName.has(column.name)) continue
167
+ byName.set(column.name, {
168
+ name: column.name,
169
+ label: column.header === '' ? undefined : column.header,
163
170
  fallback: true,
164
- }))
171
+ })
172
+ }
173
+ return [...byName.values()]
165
174
  }, [columns, currentLayout, layoutElements])
166
175
  }
@@ -43,11 +43,17 @@ export interface CreateColumnConfig<TValue, TFilterArtifact extends FilterArtifa
43
43
  // Column Component Props
44
44
  // ============================================================================
45
45
 
46
- export interface ColumnComponentProps<TValue = unknown> {
46
+ export interface ColumnComponentProps<TValue = unknown, TFilterArtifact extends FilterArtifact = FilterArtifact> {
47
47
  field: FieldRef<TValue>
48
+ /** Column identity. Defaults to the bound field name — pass it when two columns bind one field. */
49
+ name?: string
48
50
  header?: React.ReactNode
49
51
  sortable?: boolean
50
52
  filter?: boolean
53
+ /** Where the column's filter starts when nothing is stored for it. Defaults to the handler's inactive artifact. */
54
+ filterInitialArtifact?: TFilterArtifact
55
+ /** Register the field without showing a column (e.g. to make it full-text searchable). */
56
+ virtual?: boolean
51
57
  children?: (value: TValue | null, accessor: EntityAccessor<object>) => React.ReactNode
52
58
  }
53
59
 
@@ -55,8 +61,12 @@ export interface ColumnComponentProps<TValue = unknown> {
55
61
  // Factory
56
62
  // ============================================================================
57
63
 
58
- export interface ColumnComponent<TExtraProps = object> {
59
- <T>(props: ColumnComponentProps<T> & TExtraProps): null
64
+ /**
65
+ * `TFilterArtifact` defaults to `never`: a column whose artifact type is not known takes no
66
+ * `filterInitialArtifact`, and every column `createColumn` builds is assignable to it.
67
+ */
68
+ export interface ColumnComponent<TExtraProps = object, TFilterArtifact extends FilterArtifact = never> {
69
+ <T>(props: ColumnComponentProps<T, TFilterArtifact> & TExtraProps): null
60
70
  staticRender: (props: Record<string, unknown>) => React.ReactNode
61
71
  }
62
72
 
@@ -106,7 +116,8 @@ export function createColumnStaticRender<TValue, TFilterArtifact extends FilterA
106
116
  }
107
117
 
108
118
  const leafProps = {
109
- name: fieldName ?? `col-${Math.random().toString(36).slice(2, 8)}`,
119
+ name: props['name'] as string | undefined,
120
+ virtual: props['virtual'] as boolean | undefined,
110
121
  fieldName,
111
122
  fieldRef: fieldRef ?? null,
112
123
  sortingField: sortable && fieldName ? fieldName : null,
@@ -114,6 +125,7 @@ export function createColumnStaticRender<TValue, TFilterArtifact extends FilterA
114
125
  filterHandler: filterEnabled && fieldName
115
126
  ? columnType.createFilterHandler(fieldName) as FilterHandler<FilterArtifact>
116
127
  : undefined,
128
+ filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
117
129
  isTextSearchable: columnType.isTextSearchable,
118
130
  columnType: columnType.name as ColumnLeafProps['columnType'],
119
131
  enumName,
@@ -135,10 +147,10 @@ export function createColumnStaticRender<TValue, TFilterArtifact extends FilterA
135
147
  export function createColumn<TValue, TFilterArtifact extends FilterArtifact, TExtraProps = object>(
136
148
  columnType: ColumnTypeDef<TValue, TFilterArtifact>,
137
149
  config: CreateColumnConfig<TValue, TFilterArtifact>,
138
- ): ColumnComponent<TExtraProps> {
139
- function Column(_props: ColumnComponentProps<unknown> & TExtraProps): null {
150
+ ): ColumnComponent<TExtraProps, TFilterArtifact> {
151
+ function Column(_props: ColumnComponentProps<unknown, TFilterArtifact> & TExtraProps): null {
140
152
  return null
141
153
  }
142
154
  Column.staticRender = createColumnStaticRender(columnType, config)
143
- return Column as ColumnComponent<TExtraProps>
155
+ return Column as ColumnComponent<TExtraProps, TFilterArtifact>
144
156
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React from 'react'
14
- import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx'
14
+ import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, RelationFilterArtifact, SelectionMeta } from '@contember/bindx'
15
15
  import { SelectionScope } from '@contember/bindx'
16
16
  import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react'
17
17
  import type { ColumnTypeDef } from './columnTypes.js'
@@ -60,8 +60,14 @@ export interface RelationCellWrapperContext {
60
60
 
61
61
  export interface RelationColumnProps<TEntity, TSelected> {
62
62
  field: object
63
+ /** Column identity. Defaults to the bound field name — pass it when two columns bind one relation. */
64
+ name?: string
63
65
  header?: React.ReactNode
64
66
  filter?: boolean
67
+ /** Where the column's filter starts when nothing is stored for it. Defaults to no related row picked. */
68
+ filterInitialArtifact?: RelationFilterArtifact
69
+ /** Register the relation without showing a column. */
70
+ virtual?: boolean
65
71
  renderCellWrapper?: (content: React.ReactNode, item: EntityAccessor<object>) => React.ReactNode
66
72
  children: (entity: EntityAccessor<TEntity, TSelected>) => React.ReactNode
67
73
  }
@@ -149,7 +155,8 @@ export function createRelationColumn<TFilterArtifact extends FilterArtifact>(
149
155
  : undefined)
150
156
 
151
157
  return {
152
- name: fieldName ?? `col-${Math.random().toString(36).slice(2, 8)}`,
158
+ name: props['name'] as string | undefined,
159
+ virtual: props['virtual'] as boolean | undefined,
153
160
  fieldName,
154
161
  fieldRef: fieldRef ?? null,
155
162
  sortingField: null,
@@ -157,6 +164,7 @@ export function createRelationColumn<TFilterArtifact extends FilterArtifact>(
157
164
  filterHandler: filterName
158
165
  ? columnType.createFilterHandler(fieldName!) as FilterHandler<FilterArtifact>
159
166
  : undefined,
167
+ filterInitialArtifact: props['filterInitialArtifact'] as FilterArtifact | undefined,
160
168
  isTextSearchable: false,
161
169
  columnType: columnType.name as ColumnLeafProps['columnType'],
162
170
  relatedEntityName,
package/src/export.tsx CHANGED
@@ -123,7 +123,7 @@ export const DataViewExportTrigger = forwardRef<HTMLButtonElement, DataViewExpor
123
123
 
124
124
  try {
125
125
  const visibleColumns = onlyVisible
126
- ? columns.filter((c, i) => selection.isVisible(c.fieldName ?? `col-${i}`))
126
+ ? columns.filter(c => selection.isVisible(c.name))
127
127
  : columns
128
128
 
129
129
  const data = await fetchAllData()
@@ -133,7 +133,7 @@ export const DataViewExportTrigger = forwardRef<HTMLButtonElement, DataViewExpor
133
133
  }
134
134
 
135
135
  const exportColumns = visibleColumns
136
- .filter(c => c.fieldName !== null)
136
+ .filter(c => !c.virtual && c.fieldName !== null)
137
137
  .map(c => ({ name: String(c.header ?? c.fieldName ?? ''), fieldName: c.fieldName }))
138
138
 
139
139
  const { blob, extension } = exportFactory.create({
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ export {
24
24
  export {
25
25
  ColumnLeaf,
26
26
  type ColumnLeafProps,
27
+ type DataViewColumn,
28
+ resolveColumnNames,
27
29
  extractColumnLeaves,
28
30
  analyzeChildren,
29
31
  type ChildrenAnalysisResult,
@@ -46,6 +46,7 @@ import { useSelectOptions } from './selectContext.js'
46
46
  import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js'
47
47
  import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js'
48
48
  import { useListFetchAllData } from '../useListFetchAllData.js'
49
+ import { usePagingTotalCount } from '../usePagingTotalCount.js'
49
50
 
50
51
  export interface SelectDataViewProps {
51
52
  /** Children rendered inside the DataView context */
@@ -185,15 +186,13 @@ function SelectDataViewImpl({
185
186
  }
186
187
  }, [result.$status])
187
188
 
188
- // Update total count from the count query, falling back to the partial-page
189
- // heuristic until the count resolves.
190
- useEffect(() => {
191
- if (totalCount !== null) {
192
- paging.setTotalCount(totalCount)
193
- } else if (result.$status === 'ready' && paging.queryLimit !== undefined && paging.queryOffset !== undefined && itemCount < paging.queryLimit) {
194
- paging.setTotalCount(paging.queryOffset + itemCount)
195
- }
196
- }, [totalCount, result.$status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount])
189
+ const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter])
190
+ usePagingTotalCount(paging, {
191
+ countedTotal: totalCount,
192
+ countKey,
193
+ isPageCurrent: result.$status === 'ready' && !result.$isRefetching,
194
+ pageItemCount: itemCount,
195
+ })
197
196
 
198
197
  // ---- Reload ----
199
198
  const [, setReloadCounter] = useState(0)
@@ -21,7 +21,7 @@ import {
21
21
  mergeSelections,
22
22
  collectSelection,
23
23
  } from '@contember/bindx-react'
24
- import { ColumnLeaf, type ColumnLeafProps, analyzeChildren } from './columnLeaf.js'
24
+ import { ColumnLeaf, type ColumnLeafProps, type DataViewColumn, analyzeChildren, resolveColumnNames } from './columnLeaf.js'
25
25
  import { DataGridToolbarContent, type DataGridToolbarContentProps } from './markers.js'
26
26
  import { DataGridLayout, type DataGridLayoutProps } from './markers.js'
27
27
  import { useFilteringState, useSortingState, usePagingState, useSelectionState } from './useDataViewState.js'
@@ -63,7 +63,7 @@ export interface DataGridCommonProps<TEntity extends object> {
63
63
 
64
64
  export interface DataGridSetupResult {
65
65
  childrenJsx: ReactNode
66
- columns: ColumnLeafProps[]
66
+ columns: DataViewColumn[]
67
67
  selection: SelectionMeta
68
68
  queryKey: string
69
69
  toolbarContent: ReactNode | undefined
@@ -141,7 +141,7 @@ export function useDataGridSetup<TEntity extends object>({
141
141
  return runtimeColumns
142
142
  }
143
143
 
144
- const runtimeBoundColumns = cols.map((column, index): ColumnLeafProps => ({
144
+ const runtimeBoundColumns = resolveColumnNames(cols).map((column, index): DataViewColumn => ({
145
145
  ...column,
146
146
  renderCell: accessor => {
147
147
  const runtimeColumn = getRuntimeColumns(accessor)[index]
@@ -199,12 +199,12 @@ export function useDataGridSetup<TEntity extends object>({
199
199
  }, [entityType, schemaRegistry, children, layouts])
200
200
 
201
201
  // ---- Phase 2: State management ----
202
- const filterDefs = useMemo((): ReadonlyMap<string, { handler: FilterHandler<FilterArtifact> }> => {
203
- const map = new Map<string, { handler: FilterHandler<FilterArtifact> }>()
202
+ const filterDefs = useMemo((): ReadonlyMap<string, { handler: FilterHandler<FilterArtifact>; initialArtifact?: FilterArtifact }> => {
203
+ const map = new Map<string, { handler: FilterHandler<FilterArtifact>; initialArtifact?: FilterArtifact }>()
204
204
  const textFieldPaths: string[] = []
205
205
  for (const col of columns) {
206
206
  if (col.filterName && col.filterHandler) {
207
- map.set(col.filterName, { handler: col.filterHandler })
207
+ map.set(col.filterName, { handler: col.filterHandler, initialArtifact: col.filterInitialArtifact })
208
208
  }
209
209
  if (col.isTextSearchable && col.fieldName) {
210
210
  textFieldPaths.push(col.fieldName)
@@ -5,7 +5,7 @@
5
5
  * for persisting state across navigations.
6
6
  */
7
7
 
8
- import { useState, useCallback, useMemo } from 'react'
8
+ import { useState, useCallback, useEffect, useMemo } from 'react'
9
9
  import type {
10
10
  FilterHandler,
11
11
  FilterArtifact,
@@ -40,12 +40,18 @@ export type FilterArtifactUpdate =
40
40
 
41
41
  export interface FilteringState {
42
42
  readonly filters: ReadonlyMap<string, RegisteredFilter>
43
+ /** The filter's current artifact: the stored one, else its initial artifact. `undefined` only for an unregistered filter. */
43
44
  getArtifact(name: string): FilterArtifact | undefined
44
45
  /** An updater sees the live artifact, not a render snapshot; returning `undefined` resets the filter to its default. */
45
46
  setArtifact(name: string, artifact: FilterArtifactUpdate): void
46
- /** Replaces the whole artifact record at once — for restoring a saved filter preset. */
47
+ /**
48
+ * Replaces the whole artifact record at once — for restoring a saved filter preset.
49
+ * A registered filter the record does not name reads as its initial artifact.
50
+ */
47
51
  setAllArtifacts(artifacts: Record<string, FilterArtifact>): void
52
+ /** Clears one filter: sets it to its handler's default (inactive) artifact. */
48
53
  resetFilter(name: string): void
54
+ /** Puts every filter back where it starts: its initial artifact, else the handler default. */
49
55
  resetAll(): void
50
56
  readonly hasActiveFilters: boolean
51
57
  readonly resolvedWhere: Record<string, unknown> | undefined
@@ -62,7 +68,7 @@ export interface UseFilteringOptions {
62
68
  export function useFilteringState(options: UseFilteringOptions): FilteringState {
63
69
  const { filters: filterDefs, stateStorage = 'null', storageKey = 'dataview' } = options
64
70
 
65
- const defaultArtifacts = useMemo((): Record<string, FilterArtifact> => {
71
+ const initialArtifacts = useMemo((): Record<string, FilterArtifact> => {
66
72
  const result: Record<string, FilterArtifact> = {}
67
73
  for (const [name, def] of filterDefs) {
68
74
  result[name] = def.initialArtifact ?? def.handler.defaultArtifact()
@@ -70,10 +76,18 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
70
76
  return result
71
77
  }, [filterDefs])
72
78
 
73
- const [artifacts, setArtifacts] = useStoredState<Record<string, FilterArtifact>>(
79
+ const [storedArtifacts, setArtifacts] = useStoredState<Record<string, FilterArtifact>>(
74
80
  stateStorage,
75
81
  [storageKey, 'filters'],
76
- (stored) => stored ?? defaultArtifacts,
82
+ (stored) => stored ?? initialArtifacts,
83
+ )
84
+
85
+ // A registered filter the record does not name (a record stored before the filter
86
+ // existed, or a restored preset that omits it) is where it would start with nothing
87
+ // stored. Every view below reads this one record, so the UI and the query agree.
88
+ const artifacts = useMemo(
89
+ (): Record<string, FilterArtifact> => ({ ...initialArtifacts, ...storedArtifacts }),
90
+ [initialArtifacts, storedArtifacts],
77
91
  )
78
92
 
79
93
  const getArtifact = useCallback(
@@ -85,12 +99,12 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
85
99
  const setArtifact = useCallback(
86
100
  (name: string, artifact: FilterArtifactUpdate): void => {
87
101
  setArtifacts(current => {
88
- const next = typeof artifact === 'function' ? artifact(current[name]) : artifact
102
+ const next = typeof artifact === 'function' ? artifact(current[name] ?? initialArtifacts[name]) : artifact
89
103
  const resolved = next ?? filterDefs.get(name)?.handler.defaultArtifact()
90
104
  return resolved === undefined ? current : { ...current, [name]: resolved }
91
105
  })
92
106
  },
93
- [filterDefs, setArtifacts],
107
+ [filterDefs, initialArtifacts, setArtifacts],
94
108
  )
95
109
 
96
110
  const resetFilter = useCallback(
@@ -103,8 +117,8 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
103
117
  )
104
118
 
105
119
  const resetAll = useCallback((): void => {
106
- setArtifacts(defaultArtifacts)
107
- }, [defaultArtifacts, setArtifacts])
120
+ setArtifacts(initialArtifacts)
121
+ }, [initialArtifacts, setArtifacts])
108
122
 
109
123
  const hasActiveFilters = useMemo((): boolean => {
110
124
  for (const [name, def] of filterDefs) {
@@ -135,6 +149,7 @@ export function useFilteringState(options: UseFilteringOptions): FilteringState
135
149
  map.set(name, {
136
150
  name,
137
151
  handler: def.handler,
152
+ // Stored JSON is not validated, so a `null` entry falls back to the handler default.
138
153
  artifact: artifacts[name] ?? def.handler.defaultArtifact(),
139
154
  })
140
155
  }
@@ -304,7 +319,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul
304
319
  storageKey = 'dataview',
305
320
  } = options
306
321
 
307
- const [pageIndex, setPageIndex] = useStoredState<number>(
322
+ const [requestedPageIndex, setPageIndex] = useStoredState<number>(
308
323
  currentPageStateStorage,
309
324
  [storageKey, 'pageIndex'],
310
325
  (stored) => stored ?? 0,
@@ -316,11 +331,6 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul
316
331
  (stored) => stored ?? initialItemsPerPage,
317
332
  )
318
333
 
319
- const state = useMemo((): PagingState => ({
320
- pageIndex,
321
- itemsPerPage: itemsPerPage ?? 0,
322
- }), [pageIndex, itemsPerPage])
323
-
324
334
  const [totalCount, setTotalCount] = useState<number | null>(null)
325
335
  const [refreshCounter, setRefreshCounter] = useState(0)
326
336
 
@@ -329,6 +339,19 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul
329
339
  return Math.max(1, Math.ceil(totalCount / itemsPerPage))
330
340
  }, [totalCount, itemsPerPage])
331
341
 
342
+ // A requested page past the end (the total shrank, or a stored index outlived its result set)
343
+ // reads as the last page, so the query offset never points past the rows.
344
+ const pageIndex = totalPages === null ? requestedPageIndex : Math.min(requestedPageIndex, totalPages - 1)
345
+
346
+ useEffect(() => {
347
+ if (pageIndex !== requestedPageIndex) setPageIndex(pageIndex)
348
+ }, [pageIndex, requestedPageIndex, setPageIndex])
349
+
350
+ const state = useMemo((): PagingState => ({
351
+ pageIndex,
352
+ itemsPerPage: itemsPerPage ?? 0,
353
+ }), [pageIndex, itemsPerPage])
354
+
332
355
  const info: PagingInfo = useMemo(() => ({
333
356
  totalCount,
334
357
  totalPages,