@contember/bindx-dataview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/DataGrid.d.ts +38 -0
  2. package/dist/DataGrid.d.ts.map +1 -0
  3. package/dist/DataViewContext.d.ts +39 -0
  4. package/dist/DataViewContext.d.ts.map +1 -0
  5. package/dist/DataViewKeyProvider.d.ts +8 -0
  6. package/dist/DataViewKeyProvider.d.ts.map +1 -0
  7. package/dist/HasManyDataGrid.d.ts +51 -0
  8. package/dist/HasManyDataGrid.d.ts.map +1 -0
  9. package/dist/columnLeaf.d.ts +72 -0
  10. package/dist/columnLeaf.d.ts.map +1 -0
  11. package/dist/columnTypes.d.ts +35 -0
  12. package/dist/columnTypes.d.ts.map +1 -0
  13. package/dist/columns.d.ts +115 -0
  14. package/dist/columns.d.ts.map +1 -0
  15. package/dist/contextHooks.d.ts +45 -0
  16. package/dist/contextHooks.d.ts.map +1 -0
  17. package/dist/createColumn.d.ts +37 -0
  18. package/dist/createColumn.d.ts.map +1 -0
  19. package/dist/createRelationColumn.d.ts +57 -0
  20. package/dist/createRelationColumn.d.ts.map +1 -0
  21. package/dist/dataAttribute.d.ts +6 -0
  22. package/dist/dataAttribute.d.ts.map +1 -0
  23. package/dist/export.d.ts +46 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/filterComponents.d.ts +127 -0
  26. package/dist/filterComponents.d.ts.map +1 -0
  27. package/dist/filterContext.d.ts +10 -0
  28. package/dist/filterContext.d.ts.map +1 -0
  29. package/dist/filterHooks.d.ts +74 -0
  30. package/dist/filterHooks.d.ts.map +1 -0
  31. package/dist/filterScope.d.ts +54 -0
  32. package/dist/filterScope.d.ts.map +1 -0
  33. package/dist/filterWrappers.d.ts +66 -0
  34. package/dist/filterWrappers.d.ts.map +1 -0
  35. package/dist/index.d.ts +28 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/infiniteLoad.d.ts +32 -0
  38. package/dist/infiniteLoad.d.ts.map +1 -0
  39. package/dist/loaderComponents.d.ts +75 -0
  40. package/dist/loaderComponents.d.ts.map +1 -0
  41. package/dist/markers.d.ts +36 -0
  42. package/dist/markers.d.ts.map +1 -0
  43. package/dist/pagingComponents.d.ts +50 -0
  44. package/dist/pagingComponents.d.ts.map +1 -0
  45. package/dist/renderCellValue.d.ts +9 -0
  46. package/dist/renderCellValue.d.ts.map +1 -0
  47. package/dist/select/MultiSelect.d.ts +27 -0
  48. package/dist/select/MultiSelect.d.ts.map +1 -0
  49. package/dist/select/Select.d.ts +27 -0
  50. package/dist/select/Select.d.ts.map +1 -0
  51. package/dist/select/SelectDataView.d.ts +46 -0
  52. package/dist/select/SelectDataView.d.ts.map +1 -0
  53. package/dist/select/SelectEachValue.d.ts +20 -0
  54. package/dist/select/SelectEachValue.d.ts.map +1 -0
  55. package/dist/select/SelectItemTrigger.d.ts +25 -0
  56. package/dist/select/SelectItemTrigger.d.ts.map +1 -0
  57. package/dist/select/SelectOption.d.ts +23 -0
  58. package/dist/select/SelectOption.d.ts.map +1 -0
  59. package/dist/select/SelectPlaceholder.d.ts +16 -0
  60. package/dist/select/SelectPlaceholder.d.ts.map +1 -0
  61. package/dist/select/index.d.ts +9 -0
  62. package/dist/select/index.d.ts.map +1 -0
  63. package/dist/select/selectContext.d.ts +20 -0
  64. package/dist/select/selectContext.d.ts.map +1 -0
  65. package/dist/selectionComponents.d.ts +62 -0
  66. package/dist/selectionComponents.d.ts.map +1 -0
  67. package/dist/sortingComponents.d.ts +39 -0
  68. package/dist/sortingComponents.d.ts.map +1 -0
  69. package/dist/stateStorage.d.ts +20 -0
  70. package/dist/stateStorage.d.ts.map +1 -0
  71. package/dist/useDataGridSetup.d.ts +53 -0
  72. package/dist/useDataGridSetup.d.ts.map +1 -0
  73. package/dist/useDataView.d.ts +57 -0
  74. package/dist/useDataView.d.ts.map +1 -0
  75. package/dist/useDataViewState.d.ts +89 -0
  76. package/dist/useDataViewState.d.ts.map +1 -0
  77. package/package.json +33 -0
  78. package/src/DataGrid.tsx +173 -0
  79. package/src/DataViewContext.tsx +56 -0
  80. package/src/DataViewKeyProvider.tsx +16 -0
  81. package/src/HasManyDataGrid.tsx +319 -0
  82. package/src/columnLeaf.ts +148 -0
  83. package/src/columnTypes.ts +160 -0
  84. package/src/columns.tsx +306 -0
  85. package/src/contextHooks.ts +157 -0
  86. package/src/createColumn.ts +128 -0
  87. package/src/createRelationColumn.tsx +203 -0
  88. package/src/dataAttribute.ts +7 -0
  89. package/src/export.tsx +219 -0
  90. package/src/filterComponents.tsx +617 -0
  91. package/src/filterContext.ts +27 -0
  92. package/src/filterHooks.ts +386 -0
  93. package/src/filterScope.tsx +89 -0
  94. package/src/filterWrappers.tsx +187 -0
  95. package/src/index.ts +356 -0
  96. package/src/infiniteLoad.tsx +188 -0
  97. package/src/loaderComponents.tsx +239 -0
  98. package/src/markers.ts +54 -0
  99. package/src/pagingComponents.tsx +149 -0
  100. package/src/select/MultiSelect.tsx +88 -0
  101. package/src/select/Select.tsx +88 -0
  102. package/src/select/SelectDataView.tsx +231 -0
  103. package/src/select/SelectEachValue.tsx +34 -0
  104. package/src/select/SelectItemTrigger.tsx +51 -0
  105. package/src/select/SelectOption.tsx +40 -0
  106. package/src/select/SelectPlaceholder.tsx +22 -0
  107. package/src/select/index.ts +17 -0
  108. package/src/select/selectContext.ts +82 -0
  109. package/src/selectionComponents.tsx +160 -0
  110. package/src/sortingComponents.tsx +104 -0
  111. package/src/stateStorage.ts +188 -0
  112. package/src/useDataGridSetup.ts +230 -0
  113. package/src/useDataView.ts +148 -0
  114. package/src/useDataViewState.ts +404 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDataViewState.d.ts","sourceRoot":"","sources":["../src/useDataViewState.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EACX,aAAa,EACb,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,sBAAsB,EACtB,WAAW,EACX,UAAU,EACV,cAAc,EACd,cAAc,EACd,eAAe,EACf,cAAc,EACd,YAAY,EACZ,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAM3E,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,cAAc,CAAC,CAAA;IAC/C,QAAQ,EAAE,cAAc,CAAA;CACxB;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;IACvD,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAAA;IACrD,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IACzD,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,QAAQ,IAAI,IAAI,CAAA;IAChB,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;CAC3D;AAED,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,OAAO,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;QAAC,eAAe,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC,CAAA;IAC1G,8DAA8D;IAC9D,YAAY,CAAC,EAAE,kBAAkB,CAAA;IACjC,yBAAyB;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,cAAc,CA8E9E;AAMD,MAAM,WAAW,iBAAiB;IACjC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACnC,cAAc,CAAC,EAAE,iBAAiB,CAAA;IAClC,YAAY,CAAC,EAAE,kBAAkB,CAAA;IACjC,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,UAAU,CAAC,CAAC,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAC7F,KAAK,IAAI,IAAI,CAAA;IACb,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,cAAc,GAAG,IAAI,CAAA;IAC7D,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,SAAS,CAAA;CACxE;AAsBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,kBAAkB,CAmD9E;AAeD,MAAM,WAAW,gBAAgB;IAChC,2DAA2D;IAC3D,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,qCAAqC;IACrC,uBAAuB,CAAC,EAAE,kBAAkB,CAAA;IAC5C,6CAA6C;IAC7C,qBAAqB,CAAC,EAAE,kBAAkB,CAAA;IAC1C,yBAAyB;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;IAC7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,IAAI,IAAI,IAAI,CAAA;IACZ,QAAQ,IAAI,IAAI,CAAA;IAChB,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;IACZ,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAC3C,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,iBAAiB,IAAI,IAAI,CAAA;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;CACxC;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,gBAAqB,GAAG,iBAAiB,CAoFhF;AAMD,MAAM,WAAW,mBAAmB;IACnC,OAAO,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;IACnC,gBAAgB,CAAC,EAAE,eAAe,CAAA;IAClC,YAAY,CAAC,EAAE,kBAAkB,CAAA;IACjC,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;IAC9B,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IAC3C,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI,CAAA;IAC/D,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;IACpD,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;CAC1C;AAID,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,mBAAwB,GAAG,oBAAoB,CA8BzF"}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@contember/bindx-dataview",
3
+ "version": "0.1.0",
4
+ "description": "Headless DataGrid and DataView components for bindx",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc --build",
12
+ "typecheck": "tsc --build"
13
+ },
14
+ "peerDependencies": {
15
+ "react": ">=18.0.0"
16
+ },
17
+ "dependencies": {
18
+ "@contember/bindx": "0.1.0",
19
+ "@contember/bindx-react": "0.1.0",
20
+ "@radix-ui/primitive": "^1.1.3",
21
+ "@radix-ui/react-slot": "^1.2.4"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/contember/bindx.git",
31
+ "directory": "packages/bindx-dataview"
32
+ }
33
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * DataGrid component — headless data grid for top-level entity lists.
3
+ * Uses useEntityList for data loading.
4
+ *
5
+ * For has-many relation grids, use HasManyDataGrid instead.
6
+ */
7
+
8
+ import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react'
9
+ import type {
10
+ EntityDef,
11
+ EntityAccessor,
12
+ OrderDirection,
13
+ DataViewLayout,
14
+ SelectionValues,
15
+ CommonEntity,
16
+ } from '@contember/bindx'
17
+ import type { StateStorageOrName } from './stateStorage.js'
18
+ import {
19
+ useBindxContext,
20
+ useEntityList,
21
+ } from '@contember/bindx-react'
22
+ import { useDataViewKey } from './DataViewKeyProvider.js'
23
+ import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js'
24
+ import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js'
25
+
26
+ export { QUERY_FILTER_NAME }
27
+
28
+ // ============================================================================
29
+ // DataGrid Props
30
+ // ============================================================================
31
+
32
+ export interface DataGridProps<TRoleMap extends Record<string, object> = Record<string, object>> {
33
+ /** Entity definition */
34
+ entity: EntityDef<TRoleMap>
35
+ /** Children render function: receives entity proxy `it`, returns column markers + layout */
36
+ children: (it: EntityAccessor<CommonEntity<TRoleMap>>) => ReactNode
37
+ /** Initial sorting (supports multi-column: { title: 'asc', date: 'desc' }) */
38
+ initialSorting?: Partial<Record<string, OrderDirection>>
39
+ /** Items per page. null = show all. Default: 50 */
40
+ itemsPerPage?: number | null
41
+ /** Static filter (combined with dynamic filters) */
42
+ filter?: Record<string, unknown>
43
+ /** Available layouts */
44
+ layouts?: readonly DataViewLayout[]
45
+ /** Initial selection (layout + visibility) */
46
+ initialSelection?: SelectionValues
47
+ /** State storage for filter/sorting/selection state */
48
+ stateStorage?: StateStorageOrName
49
+ /** State storage for current page */
50
+ currentPageStateStorage?: StateStorageOrName
51
+ /** State storage for page size preference */
52
+ pagingSettingsStorage?: StateStorageOrName
53
+ /** Storage key prefix for isolation */
54
+ storageKey?: string
55
+ }
56
+
57
+ // ============================================================================
58
+ // Implementation
59
+ // ============================================================================
60
+
61
+ function DataGridImpl<TRoleMap extends Record<string, object>>({
62
+ entity,
63
+ children,
64
+ initialSorting,
65
+ itemsPerPage = 50,
66
+ filter: staticFilter,
67
+ layouts,
68
+ initialSelection,
69
+ stateStorage,
70
+ currentPageStateStorage,
71
+ pagingSettingsStorage,
72
+ storageKey,
73
+ }: DataGridProps<TRoleMap>): ReactElement | null {
74
+ const { schema: schemaRegistry } = useBindxContext()
75
+ const entityType = entity.$name
76
+ const contextKey = useDataViewKey()
77
+ const effectiveStorageKey = storageKey ?? contextKey
78
+
79
+ const setup = useDataGridSetup<CommonEntity<TRoleMap>>({
80
+ entityType,
81
+ schemaRegistry,
82
+ children,
83
+ initialSorting,
84
+ itemsPerPage,
85
+ staticFilter,
86
+ layouts,
87
+ initialSelection,
88
+ stateStorage,
89
+ currentPageStateStorage,
90
+ pagingSettingsStorage,
91
+ storageKey: effectiveStorageKey,
92
+ })
93
+
94
+ // ---- Loader state tracking ----
95
+ const hasLoadedOnce = useRef(false)
96
+ const [loaderState, setLoaderState] = useState<DataViewLoaderState>('initial')
97
+
98
+ // ---- Load data via useEntityList ----
99
+ const result = useEntityList(entity, {
100
+ filter: setup.combinedFilter,
101
+ orderBy: setup.sorting.resolvedOrderBy,
102
+ limit: setup.paging.queryLimit,
103
+ offset: setup.paging.queryOffset,
104
+ selection: setup.selection,
105
+ queryKey: setup.queryKey,
106
+ })
107
+
108
+ const items = result.status === 'ready' ? result.items : []
109
+ const itemCount = items.length
110
+
111
+ // Update loader state
112
+ useEffect(() => {
113
+ if (result.status === 'ready') {
114
+ hasLoadedOnce.current = true
115
+ setLoaderState('loaded')
116
+ } else if (result.status === 'error') {
117
+ setLoaderState('failed')
118
+ } else if (result.status === 'loading') {
119
+ setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial')
120
+ }
121
+ }, [result.status])
122
+
123
+ // Update total count when data is ready
124
+ useEffect(() => {
125
+ if (result.status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) {
126
+ setup.paging.setTotalCount(setup.paging.queryOffset + itemCount)
127
+ }
128
+ }, [result.status, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount])
129
+
130
+ // ---- Reload ----
131
+ const [, setReloadCounter] = useState(0)
132
+ const reload = useCallback((): void => {
133
+ setReloadCounter(c => c + 1)
134
+ }, [])
135
+
136
+ // ---- Row highlighting ----
137
+ const [highlightIndex, setHighlightIndex] = useState<number | null>(null)
138
+
139
+ useEffect(() => {
140
+ setHighlightIndex(null)
141
+ }, [items])
142
+
143
+ const contextValue = useMemo((): DataViewContextValue => ({
144
+ filtering: setup.filtering,
145
+ sorting: setup.sorting,
146
+ paging: setup.paging,
147
+ selection: setup.selectionState,
148
+ columns: setup.columns,
149
+ entityType,
150
+ items,
151
+ itemCount,
152
+ loaderState,
153
+ reload,
154
+ highlightIndex,
155
+ setHighlightIndex,
156
+ selectionMeta: setup.selection,
157
+ toolbarContent: setup.toolbarContent,
158
+ layoutRenders: setup.layoutRenders,
159
+ layoutElements: setup.layoutElements,
160
+ }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, entityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements])
161
+
162
+ return (
163
+ <DataViewProvider value={contextValue}>
164
+ {setup.childrenJsx}
165
+ </DataViewProvider>
166
+ )
167
+ }
168
+
169
+ // ============================================================================
170
+ // Export
171
+ // ============================================================================
172
+
173
+ export const DataGrid = memo(DataGridImpl) as unknown as typeof DataGridImpl
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DataView context — provides filtering, sorting, paging, selection state,
3
+ * and utility methods to descendant components.
4
+ */
5
+
6
+ import React, { createContext, useContext } from 'react'
7
+ import type { FilteringState, SortingStateResult, PagingStateResult, SelectionStateResult } from './useDataViewState.js'
8
+ import type { ColumnLeafProps } from './columnLeaf.js'
9
+ import type { EntityAccessor, SelectionMeta } from '@contember/bindx'
10
+
11
+ export type DataViewLoaderState = 'initial' | 'loaded' | 'refreshing' | 'failed'
12
+
13
+ export type DataViewItem = EntityAccessor<object>
14
+
15
+ export interface DataViewElementData {
16
+ readonly name: string
17
+ readonly label?: React.ReactNode
18
+ readonly fallback?: boolean
19
+ }
20
+
21
+ export interface DataViewContextValue {
22
+ readonly filtering: FilteringState
23
+ readonly sorting: SortingStateResult
24
+ readonly paging: PagingStateResult
25
+ readonly selection: SelectionStateResult
26
+ readonly columns: readonly ColumnLeafProps[]
27
+ readonly entityType: string
28
+ readonly items: readonly DataViewItem[]
29
+ readonly itemCount: number
30
+ readonly loaderState: DataViewLoaderState
31
+ readonly reload: () => void
32
+ readonly highlightIndex: number | null
33
+ readonly setHighlightIndex: (index: number | null) => void
34
+ readonly selectionMeta: SelectionMeta
35
+ readonly toolbarContent?: React.ReactNode
36
+ /** Named layout render callbacks — analyzed during collection, called per item at runtime */
37
+ readonly layoutRenders: ReadonlyMap<string, (item: DataViewItem) => React.ReactNode>
38
+ /** Elements per layout — extracted from layout render callbacks during collection */
39
+ readonly layoutElements: ReadonlyMap<string, readonly DataViewElementData[]>
40
+ }
41
+
42
+ const DataViewContext = createContext<DataViewContextValue | null>(null)
43
+
44
+ export function useDataViewContext(): DataViewContextValue {
45
+ const ctx = useContext(DataViewContext)
46
+ if (!ctx) {
47
+ throw new Error('useDataViewContext must be used within a DataViewProvider')
48
+ }
49
+ return ctx
50
+ }
51
+
52
+ export function useOptionalDataViewContext(): DataViewContextValue | null {
53
+ return useContext(DataViewContext)
54
+ }
55
+
56
+ export const DataViewProvider = DataViewContext.Provider
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ const DataViewKeyContext = createContext<string | undefined>(undefined)
4
+
5
+ export interface DataViewKeyProviderProps {
6
+ readonly value: string
7
+ readonly children: ReactNode
8
+ }
9
+
10
+ export function DataViewKeyProvider({ value, children }: DataViewKeyProviderProps): ReactNode {
11
+ return <DataViewKeyContext value={value}>{children}</DataViewKeyContext>
12
+ }
13
+
14
+ export function useDataViewKey(fallback = 'dataview'): string {
15
+ return useContext(DataViewKeyContext) ?? fallback
16
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * HasManyDataGrid — headless data grid for has-many relation fields.
3
+ * Fetches relation data from the parent entity with dynamic filter/sort/page params.
4
+ *
5
+ * @example
6
+ * ```tsx
7
+ * <Entity entity={schema.Author} by={{ id }}>
8
+ * {(author) => (
9
+ * <HasManyDataGrid field={author.articles}>
10
+ * {(it) => (
11
+ * <>
12
+ * <TextColumn field={it.title} />
13
+ * <BooleanColumn field={it.active} />
14
+ * </>
15
+ * )}
16
+ * </HasManyDataGrid>
17
+ * )}
18
+ * </Entity>
19
+ * ```
20
+ */
21
+
22
+ import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react'
23
+ import type {
24
+ EntityAccessor,
25
+ HasManyRef,
26
+ OrderDirection,
27
+ DataViewLayout,
28
+ SelectionValues,
29
+ } from '@contember/bindx'
30
+ import { FIELD_REF_META } from '@contember/bindx'
31
+ import {
32
+ buildQueryFromSelection,
33
+ generateHasManyAlias,
34
+ EntityHandle,
35
+ setEntityData,
36
+ } from '@contember/bindx'
37
+ import type { SchemaRegistry } from '@contember/bindx'
38
+ import type { StateStorageOrName } from './stateStorage.js'
39
+ import {
40
+ useBindxContext,
41
+ } from '@contember/bindx-react'
42
+ import { useDataViewKey } from './DataViewKeyProvider.js'
43
+ import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js'
44
+ import { useDataGridSetup } from './useDataGridSetup.js'
45
+
46
+ // ============================================================================
47
+ // Props
48
+ // ============================================================================
49
+
50
+ export interface HasManyDataGridProps<TEntity extends object = object> {
51
+ /** Has-many relation field from parent entity */
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ field: HasManyRef<TEntity, any>
54
+ /** Children render function: receives entity proxy `it`, returns column markers + layout */
55
+ children: (it: EntityAccessor<TEntity>) => ReactNode
56
+ /** Initial sorting */
57
+ initialSorting?: Partial<Record<string, OrderDirection>>
58
+ /** Items per page. null = show all. Default: 50 */
59
+ itemsPerPage?: number | null
60
+ /** Static filter (combined with dynamic filters) */
61
+ filter?: Record<string, unknown>
62
+ /** Available layouts */
63
+ layouts?: readonly DataViewLayout[]
64
+ /** Initial selection (layout + visibility) */
65
+ initialSelection?: SelectionValues
66
+ /** State storage for filter/sorting/selection state */
67
+ stateStorage?: StateStorageOrName
68
+ /** State storage for current page */
69
+ currentPageStateStorage?: StateStorageOrName
70
+ /** State storage for page size preference */
71
+ pagingSettingsStorage?: StateStorageOrName
72
+ /** Storage key prefix for isolation */
73
+ storageKey?: string
74
+ }
75
+
76
+ // ============================================================================
77
+ // List state
78
+ // ============================================================================
79
+
80
+ interface ListState {
81
+ status: 'loading' | 'error' | 'ready'
82
+ items: Array<{ id: string; data: object }>
83
+ totalCount?: number
84
+ }
85
+
86
+ const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] }
87
+
88
+ // ============================================================================
89
+ // Implementation
90
+ // ============================================================================
91
+
92
+ function HasManyDataGridImpl<TEntity extends object>({
93
+ field,
94
+ children,
95
+ initialSorting,
96
+ itemsPerPage = 50,
97
+ filter: staticFilter,
98
+ layouts,
99
+ initialSelection,
100
+ stateStorage,
101
+ currentPageStateStorage,
102
+ pagingSettingsStorage,
103
+ storageKey,
104
+ }: HasManyDataGridProps<TEntity>): ReactElement | null {
105
+ const { schema: schemaRegistry, store, dispatcher, batcher } = useBindxContext()
106
+
107
+ // Extract relation metadata from field prop
108
+ const fieldMeta = field[FIELD_REF_META]
109
+ const parentEntityType = fieldMeta.entityType
110
+ const parentEntityId = fieldMeta.entityId
111
+ const fieldName = fieldMeta.fieldName
112
+ const targetEntityType = field.__entityName
113
+
114
+ const contextKey = useDataViewKey()
115
+ const effectiveStorageKey = storageKey ?? `${parentEntityType}:${fieldName}`
116
+
117
+ const setup = useDataGridSetup<TEntity>({
118
+ entityType: targetEntityType,
119
+ schemaRegistry,
120
+ children,
121
+ initialSorting,
122
+ itemsPerPage,
123
+ staticFilter,
124
+ layouts,
125
+ initialSelection,
126
+ stateStorage,
127
+ currentPageStateStorage,
128
+ pagingSettingsStorage,
129
+ storageKey: effectiveStorageKey,
130
+ })
131
+
132
+ // ---- State ----
133
+ const hasLoadedOnce = useRef(false)
134
+ const [loaderState, setLoaderState] = useState<DataViewLoaderState>('initial')
135
+ const [listState, setListState] = useState<ListState>(INITIAL_LIST_STATE)
136
+
137
+ // ---- Build relation query spec ----
138
+ const alias = useMemo(
139
+ () => generateHasManyAlias(fieldName, {
140
+ filter: setup.combinedFilter,
141
+ orderBy: setup.sorting.resolvedOrderBy as unknown[],
142
+ limit: setup.paging.queryLimit,
143
+ offset: setup.paging.queryOffset,
144
+ }),
145
+ [fieldName, setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset],
146
+ )
147
+
148
+ const optionsKey = useMemo(
149
+ () => JSON.stringify({
150
+ filter: setup.combinedFilter ?? {},
151
+ orderBy: setup.sorting.resolvedOrderBy ?? [],
152
+ limit: setup.paging.queryLimit,
153
+ offset: setup.paging.queryOffset,
154
+ }),
155
+ [setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset],
156
+ )
157
+
158
+ // ---- Data loading via GetQuery on parent ----
159
+ useEffect(() => {
160
+ const abortController = new AbortController()
161
+ setListState(INITIAL_LIST_STATE)
162
+
163
+ const fetchData = async (): Promise<void> => {
164
+ try {
165
+ const targetSpec = buildQueryFromSelection(setup.selection)
166
+ const currentOptions = JSON.parse(optionsKey) as {
167
+ filter: Record<string, unknown>
168
+ orderBy: readonly Record<string, unknown>[]
169
+ limit?: number
170
+ offset?: number
171
+ }
172
+
173
+ const parentSpec = {
174
+ fields: [
175
+ { name: 'id', sourcePath: ['id'] },
176
+ {
177
+ name: alias,
178
+ sourcePath: [fieldName],
179
+ isArray: true as const,
180
+ totalCount: true,
181
+ filter: Object.keys(currentOptions.filter).length > 0 ? currentOptions.filter : undefined,
182
+ orderBy: currentOptions.orderBy.length > 0 ? currentOptions.orderBy : undefined,
183
+ limit: currentOptions.limit,
184
+ offset: currentOptions.offset,
185
+ nested: targetSpec,
186
+ },
187
+ ],
188
+ }
189
+
190
+ const result = await batcher.enqueue(
191
+ {
192
+ type: 'get',
193
+ entityType: parentEntityType,
194
+ by: { id: parentEntityId },
195
+ spec: parentSpec,
196
+ },
197
+ { signal: abortController.signal },
198
+ )
199
+
200
+ if (abortController.signal.aborted) return
201
+
202
+ if (result.type !== 'get' || !result.data) {
203
+ setListState({ status: 'error', items: [] })
204
+ return
205
+ }
206
+
207
+ const relationData = (result.data[alias] ?? result.data[fieldName]) as Array<Record<string, unknown>> | undefined
208
+ const totalCount = Array.isArray(relationData) && 'totalCount' in relationData
209
+ ? (relationData as unknown as { totalCount: number }).totalCount
210
+ : undefined
211
+
212
+ if (!Array.isArray(relationData)) {
213
+ setListState({ status: 'ready', items: [], totalCount })
214
+ return
215
+ }
216
+
217
+ const items = relationData.map((data: Record<string, unknown>) => {
218
+ const id = data['id'] as string
219
+ dispatcher.dispatch(
220
+ setEntityData(targetEntityType, id, data, true),
221
+ )
222
+ return { id, data: data as object }
223
+ })
224
+
225
+ setListState({ status: 'ready', items, totalCount })
226
+ } catch (error) {
227
+ if (abortController.signal.aborted) return
228
+ setListState({ status: 'error', items: [] })
229
+ }
230
+ }
231
+
232
+ fetchData()
233
+
234
+ return () => {
235
+ abortController.abort()
236
+ }
237
+ }, [parentEntityType, parentEntityId, fieldName, targetEntityType, alias, optionsKey, setup.selection, batcher, dispatcher, store])
238
+
239
+ // ---- Build items from state ----
240
+ const items = useMemo((): EntityAccessor<TEntity>[] => {
241
+ if (listState.status !== 'ready') return []
242
+ return listState.items.map((item) =>
243
+ EntityHandle.create<TEntity>(
244
+ item.id,
245
+ targetEntityType,
246
+ store,
247
+ dispatcher,
248
+ schemaRegistry as SchemaRegistry<Record<string, object>>,
249
+ ) as unknown as EntityAccessor<TEntity>,
250
+ )
251
+ }, [listState, targetEntityType, store, dispatcher, schemaRegistry])
252
+
253
+ const itemCount = items.length
254
+
255
+ // Update loader state
256
+ useEffect(() => {
257
+ if (listState.status === 'ready') {
258
+ hasLoadedOnce.current = true
259
+ setLoaderState('loaded')
260
+ } else if (listState.status === 'error') {
261
+ setLoaderState('failed')
262
+ } else if (listState.status === 'loading') {
263
+ setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial')
264
+ }
265
+ }, [listState.status])
266
+
267
+ // Update total count from paginateRelation
268
+ useEffect(() => {
269
+ if (listState.totalCount !== undefined) {
270
+ setup.paging.setTotalCount(listState.totalCount)
271
+ } else if (listState.status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) {
272
+ setup.paging.setTotalCount(setup.paging.queryOffset + itemCount)
273
+ }
274
+ }, [listState.status, listState.totalCount, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount])
275
+
276
+ // ---- Reload ----
277
+ const [, setReloadCounter] = useState(0)
278
+ const reload = useCallback((): void => {
279
+ setReloadCounter(c => c + 1)
280
+ }, [])
281
+
282
+ // ---- Row highlighting ----
283
+ const [highlightIndex, setHighlightIndex] = useState<number | null>(null)
284
+
285
+ useEffect(() => {
286
+ setHighlightIndex(null)
287
+ }, [items])
288
+
289
+ const contextValue = useMemo((): DataViewContextValue => ({
290
+ filtering: setup.filtering,
291
+ sorting: setup.sorting,
292
+ paging: setup.paging,
293
+ selection: setup.selectionState,
294
+ columns: setup.columns,
295
+ entityType: targetEntityType,
296
+ items,
297
+ itemCount,
298
+ loaderState,
299
+ reload,
300
+ highlightIndex,
301
+ setHighlightIndex,
302
+ selectionMeta: setup.selection,
303
+ toolbarContent: setup.toolbarContent,
304
+ layoutRenders: setup.layoutRenders,
305
+ layoutElements: setup.layoutElements,
306
+ }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, targetEntityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements])
307
+
308
+ return (
309
+ <DataViewProvider value={contextValue}>
310
+ {setup.childrenJsx}
311
+ </DataViewProvider>
312
+ )
313
+ }
314
+
315
+ // ============================================================================
316
+ // Export
317
+ // ============================================================================
318
+
319
+ export const HasManyDataGrid = memo(HasManyDataGridImpl) as unknown as typeof HasManyDataGridImpl