@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.
- package/dist/DataGrid.d.ts.map +1 -1
- package/dist/DataViewContext.d.ts +2 -2
- package/dist/DataViewContext.d.ts.map +1 -1
- package/dist/HasManyDataGrid.d.ts.map +1 -1
- package/dist/columnLeaf.d.ts +32 -1
- package/dist/columnLeaf.d.ts.map +1 -1
- package/dist/columns.d.ts +20 -8
- package/dist/columns.d.ts.map +1 -1
- package/dist/contextHooks.d.ts.map +1 -1
- package/dist/createColumn.d.ts +14 -4
- package/dist/createColumn.d.ts.map +1 -1
- package/dist/createRelationColumn.d.ts +7 -1
- package/dist/createRelationColumn.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/select/SelectDataView.d.ts.map +1 -1
- package/dist/useDataGridSetup.d.ts +2 -2
- package/dist/useDataGridSetup.d.ts.map +1 -1
- package/dist/useDataViewState.d.ts +7 -1
- package/dist/useDataViewState.d.ts.map +1 -1
- package/dist/usePagingTotalCount.d.ts +19 -0
- package/dist/usePagingTotalCount.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/DataGrid.tsx +8 -11
- package/src/DataViewContext.tsx +2 -2
- package/src/HasManyDataGrid.tsx +28 -17
- package/src/columnLeaf.ts +56 -1
- package/src/columns.tsx +20 -4
- package/src/contextHooks.ts +16 -7
- package/src/createColumn.ts +19 -7
- package/src/createRelationColumn.tsx +10 -2
- package/src/export.tsx +2 -2
- package/src/index.ts +2 -0
- package/src/select/SelectDataView.tsx +8 -9
- package/src/useDataGridSetup.ts +6 -6
- package/src/useDataViewState.ts +38 -15
- package/src/usePagingTotalCount.ts +90 -0
package/src/HasManyDataGrid.tsx
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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>) => {
|
package/src/contextHooks.ts
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/createColumn.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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)
|
package/src/useDataGridSetup.ts
CHANGED
|
@@ -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:
|
|
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):
|
|
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)
|
package/src/useDataViewState.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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 [
|
|
79
|
+
const [storedArtifacts, setArtifacts] = useStoredState<Record<string, FilterArtifact>>(
|
|
74
80
|
stateStorage,
|
|
75
81
|
[storageKey, 'filters'],
|
|
76
|
-
(stored) => stored ??
|
|
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(
|
|
107
|
-
}, [
|
|
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 [
|
|
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,
|