@opencorestack/opengridx 2.0.4 → 3.0.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 (221) hide show
  1. package/CHANGELOG.md +408 -3
  2. package/README.md +93 -72
  3. package/dist/index.d.ts +611 -121
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7992 -5650
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -161
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +45 -22
  29. package/docs/components/datagrid.md +14 -12
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +100 -36
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +147 -41
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +56 -19
  54. package/docs/features/pinning.md +23 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +66 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +44 -9
  60. package/docs/features/virtualization.md +64 -15
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1222 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +16 -10
  65. package/lib/components/Cell/Cell.css +13 -1
  66. package/lib/components/Cell/Cell.tsx +130 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +11 -0
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +84 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +606 -872
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  79. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  80. package/lib/components/DataGrid/GridListView.tsx +166 -23
  81. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  82. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  83. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  84. package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
  85. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  86. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  87. package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
  88. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  89. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  90. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  91. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  92. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  93. package/lib/components/Header/ColumnMenu.tsx +91 -39
  94. package/lib/components/Header/Header.css +3 -3
  95. package/lib/components/Header/Header.tsx +179 -160
  96. package/lib/components/ListView/ListView.css +25 -0
  97. package/lib/components/ListView/ListViewRow.tsx +79 -10
  98. package/lib/components/Pagination/Pagination.tsx +15 -7
  99. package/lib/components/Row/Row.tsx +185 -75
  100. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  101. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  102. package/lib/components/Toolbar/GridToolbar.css +12 -11
  103. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  104. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  105. package/lib/components/Tooltip/Tooltip.css +20 -0
  106. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  107. package/lib/components/ui/Button.tsx +3 -0
  108. package/lib/components/ui/Checkbox.tsx +2 -1
  109. package/lib/components/ui/ExpandIcon.css +0 -9
  110. package/lib/components/ui/ExpandIcon.tsx +4 -1
  111. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  112. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  113. package/lib/hooks/core/useDataGrid.ts +115 -173
  114. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  115. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  116. package/lib/hooks/core/useGridApiRef.ts +15 -3
  117. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  118. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  119. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  120. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  121. package/lib/hooks/core/useGridColumns.tsx +284 -76
  122. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  123. package/lib/hooks/core/useGridControlledState.ts +99 -6
  124. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  125. package/lib/hooks/core/useGridDevWarnings.ts +123 -0
  126. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  127. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  128. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  129. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  130. package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
  131. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  132. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  133. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  134. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  135. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  136. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  137. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  138. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  139. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  140. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  141. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  142. package/lib/hooks/core/useGridViewport.ts +38 -0
  143. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  144. package/lib/hooks/core/useGridVisibleRows.ts +20 -7
  145. package/lib/hooks/core/useLayout.ts +215 -195
  146. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  147. package/lib/hooks/core/useStableColumns.ts +34 -0
  148. package/lib/hooks/features/useAggregation.ts +86 -90
  149. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  150. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  151. package/lib/hooks/features/useGridClipboard.ts +133 -73
  152. package/lib/hooks/features/useGridDataSource.ts +354 -128
  153. package/lib/hooks/features/useGridEditing.ts +211 -51
  154. package/lib/hooks/features/useGridPivot.ts +92 -0
  155. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  156. package/lib/hooks/features/useGridSpanning.ts +410 -306
  157. package/lib/hooks/features/usePivot.ts +0 -0
  158. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  159. package/lib/hooks/useColumnReorder.ts +38 -44
  160. package/lib/hooks/useRowGrouping.ts +335 -214
  161. package/lib/hooks/useRowReorder.ts +62 -31
  162. package/lib/hooks/useTreeData.ts +330 -262
  163. package/lib/index.ts +30 -2
  164. package/lib/state/index.ts +1 -0
  165. package/lib/state/types.ts +7 -1
  166. package/lib/state/useGridStateStorage.ts +88 -29
  167. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  168. package/lib/theme/gridThemeContext.ts +5 -0
  169. package/lib/theme/index.ts +5 -0
  170. package/lib/theme/presets.ts +5 -4
  171. package/lib/theme/types.ts +40 -2
  172. package/lib/types/index.ts +311 -108
  173. package/lib/utils/aggregation/index.ts +157 -0
  174. package/lib/utils/aria/index.ts +40 -0
  175. package/lib/utils/columnGroups.ts +110 -0
  176. package/lib/utils/columnLookup.ts +33 -0
  177. package/lib/utils/columnWidth.ts +22 -0
  178. package/lib/utils/dataSource/index.ts +46 -0
  179. package/lib/utils/detailPanel.ts +66 -0
  180. package/lib/utils/dragData.ts +44 -0
  181. package/lib/utils/editing/index.ts +30 -0
  182. package/lib/utils/export/exportShared.ts +197 -0
  183. package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
  184. package/lib/utils/export/exportToPdf.ts +143 -92
  185. package/lib/utils/export/groupLabel.ts +14 -0
  186. package/lib/utils/export/index.ts +278 -272
  187. package/lib/utils/export/pdfText.ts +50 -0
  188. package/lib/utils/filtering/index.ts +277 -148
  189. package/lib/utils/focus/index.ts +115 -0
  190. package/lib/utils/gridApiRows.ts +35 -0
  191. package/lib/utils/gridModes.ts +77 -0
  192. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  193. package/lib/utils/pagination/index.ts +56 -0
  194. package/lib/utils/pinning/index.ts +77 -33
  195. package/lib/utils/pivot/index.ts +368 -0
  196. package/lib/utils/scroll/index.ts +78 -0
  197. package/lib/utils/scroll/scrollEnd.ts +18 -0
  198. package/lib/utils/sorting/index.ts +154 -30
  199. package/lib/utils/syntheticRows.ts +12 -0
  200. package/lib/utils/values.ts +206 -0
  201. package/lib/utils/viewport.ts +14 -0
  202. package/llms.txt +272 -166
  203. package/package.json +18 -6
  204. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  205. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/Header/Header.test.tsx +0 -135
  210. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  211. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  212. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  213. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  214. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  215. package/lib/hooks/features/useAggregation.test.ts +0 -175
  216. package/lib/hooks/features/usePivot.test.ts +0 -202
  217. package/lib/hooks/useRowGrouping.test.ts +0 -137
  218. package/lib/utils/export/exportToPdf.test.ts +0 -120
  219. package/lib/utils/export/groupedExport.test.ts +0 -144
  220. package/lib/utils/filtering/filtering.test.ts +0 -178
  221. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -1,299 +1,364 @@
1
1
  import { useMemo, useState, useCallback, useEffect } from 'react';
2
- import { GridRowModel, GridRowId, GridTreeNode, GridFilterModel, GridSortItem } from '../types';
3
- import type { GridRowMeta } from '../types';
4
- import { isRowMatchingFilter } from '../utils/filtering';
5
- import { compareValues } from '../utils/sorting';
2
+ import type { GridRowModel, GridRowId, GridTreeNode, GridFilterModel, GridSortItem, GridRowMeta } from '../types';
3
+ import { createRowFilter } from '../utils/filtering';
4
+ import { sortItemsBySortModel, type GridSortValue } from '../utils/sorting';
5
+ import { getCellValue } from '../utils/values';
6
+ import type { GridColumnLookup } from '../utils/columnLookup';
7
+ import type { GridHierarchyVisibleRowsOptions } from './useRowGrouping';
8
+
9
+ const EMPTY_OVERRIDES: Map<GridRowId, boolean> = new Map();
10
+
11
+ interface ExpansionOverrides {
12
+ configKey: string;
13
+ overrides: Map<GridRowId, boolean>;
14
+ }
6
15
 
7
16
  interface UseTreeDataProps<R extends GridRowModel> {
8
17
  rows: R[];
9
18
  getRowId: (row: R) => GridRowId;
19
+ /**
20
+ * Returns the hierarchy path of a row. Keep it stable (module scope or useCallback): a new
21
+ * function identity rebuilds the whole tree, because the grid cannot tell whether it returns
22
+ * the same paths without calling it for every row.
23
+ */
10
24
  getTreeDataPath?: (row: R) => string[];
11
25
  treeData?: boolean;
12
26
  defaultGroupingExpansionDepth?: number;
13
27
  filterModel?: GridFilterModel;
14
28
  sortModel?: GridSortItem[];
15
29
  onRowExpansionChange?: (node: GridTreeNode) => void;
30
+ /** Column definitions, so filter and sort read cells through valueGetter and use the column type. */
31
+ columnLookup?: GridColumnLookup;
32
+ /** 'server': the rows arrive filtered, so the tree is not filtered again on the client. */
33
+ filterMode?: 'client' | 'server';
34
+ /** 'server': the rows arrive sorted, so siblings keep the order the server sent them in. */
35
+ sortingMode?: 'client' | 'server';
16
36
  }
17
37
 
18
- export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>) {
19
- const {
20
- rows,
21
- getRowId,
22
- getTreeDataPath,
23
- treeData = false,
24
- defaultGroupingExpansionDepth = 0,
25
- filterModel,
26
- sortModel,
27
- onRowExpansionChange
28
- } = props;
38
+ /**
39
+ * The lookup key of a tree path. JSON keeps segments apart, so a segment containing '/' (a file
40
+ * name, a URL) never collides with a deeper path: ['a/b'] and ['a', 'b'] are different nodes.
41
+ */
42
+ const pathKeyOf = (path: readonly string[]): string => JSON.stringify(path);
43
+
44
+ /** The id of the parent row the grid creates for a path segment that has no row of its own. */
45
+ const autoParentIdOf = (pathKey: string): string => `auto-group-${pathKey}`;
46
+
47
+ const MAX_IDS_IN_WARNING = 5;
48
+ const formatIds = (ids: readonly GridRowId[]): string =>
49
+ ids.slice(0, MAX_IDS_IN_WARNING).map(id => JSON.stringify(id)).join(', ') + (ids.length > MAX_IDS_IN_WARNING ? ', …' : '');
50
+
51
+ interface TreeBuild {
52
+ treeNodes: Map<GridRowId, GridTreeNode>;
53
+ rootIds: GridRowId[];
54
+ /** The rows created for path segments that have no row of their own (`{ id }` only). */
55
+ groupingRows: GridRowModel[];
56
+ syntheticIds: Set<GridRowId>;
57
+ /** Every row in the tree (consumer rows and auto-created parents), by id. */
58
+ rowLookup: Map<GridRowId, GridRowModel>;
59
+ /** Development warnings about the paths (empty paths, duplicate paths). */
60
+ issues: string[];
61
+ }
29
62
 
30
- const { treeNodes, rootIds, groupingRows } = useMemo(() => {
31
- if (!treeData || !getTreeDataPath) {
32
- return {
33
- treeNodes: new Map<GridRowId, GridTreeNode>(),
34
- rootIds: [],
35
- groupingRows: []
36
- };
63
+ const EMPTY_BUILD: TreeBuild = {
64
+ treeNodes: new Map(),
65
+ rootIds: [],
66
+ groupingRows: [],
67
+ syntheticIds: new Set(),
68
+ rowLookup: new Map(),
69
+ issues: [],
70
+ };
71
+
72
+ /**
73
+ * Builds the tree in one linear pass over the rows (each child is attached exactly once, so no
74
+ * membership scans). Rows keep their order among siblings; an auto-created parent takes the position
75
+ * of the first row under it.
76
+ */
77
+ function buildTree<R extends GridRowModel>(
78
+ rows: readonly R[],
79
+ getRowId: (row: R) => GridRowId,
80
+ getTreeDataPath: (row: R) => string[],
81
+ ): TreeBuild {
82
+ const treeNodes = new Map<GridRowId, GridTreeNode>();
83
+ const rootIds: GridRowId[] = [];
84
+ const groupingRows: GridRowModel[] = [];
85
+ const syntheticIds = new Set<GridRowId>();
86
+ const rowLookup = new Map<GridRowId, GridRowModel>();
87
+ const pathLookup = new Map<string, GridRowId>();
88
+ const issues: string[] = [];
89
+
90
+ const ids: GridRowId[] = new Array(rows.length);
91
+ const paths: string[][] = new Array(rows.length);
92
+ const emptyPathIds: GridRowId[] = [];
93
+ const duplicatePaths = new Map<string, GridRowId[]>();
94
+
95
+ // Pass 1: register every row and its path, so a parent row listed after its children is found.
96
+ rows.forEach((row, index) => {
97
+ const id = getRowId(row);
98
+ const rawPath: unknown = getTreeDataPath(row);
99
+ const path = Array.isArray(rawPath) ? rawPath.map(segment => String(segment)) : [];
100
+ ids[index] = id;
101
+ paths[index] = path;
102
+ rowLookup.set(id, row);
103
+
104
+ if (path.length === 0) {
105
+ emptyPathIds.push(id);
106
+ } else {
107
+ const key = pathKeyOf(path);
108
+ const owner = pathLookup.get(key);
109
+ if (owner === undefined) {
110
+ pathLookup.set(key, id);
111
+ } else {
112
+ // Children attach to the first row with this path; the others render as childless siblings.
113
+ const clash = duplicatePaths.get(key) ?? [owner];
114
+ clash.push(id);
115
+ duplicatePaths.set(key, clash);
116
+ }
37
117
  }
38
118
 
39
- const nodes = new Map<GridRowId, GridTreeNode>();
40
- const roots: GridRowId[] = [];
41
- const groupRows: GridRowModel[] = [];
42
- const pathLookup = new Map<string, GridRowId>();
43
-
44
- const getPathKey = (path: string[]) => path.join('/');
45
-
46
- rows.forEach(row => {
47
- const id = getRowId(row);
48
- const path = getTreeDataPath(row);
49
- const pathKey = getPathKey(path);
50
-
51
- pathLookup.set(pathKey, id);
52
-
53
- nodes.set(id, {
54
- id,
55
- parentId: null,
56
- depth: path.length - 1,
57
- groupingKey: path[path.length - 1],
58
- isExpanded: false,
59
- children: [],
60
- serverChildrenCount: row.serverChildrenCount
61
- });
119
+ treeNodes.set(id, {
120
+ id,
121
+ parentId: null,
122
+ depth: Math.max(path.length - 1, 0),
123
+ groupingKey: path[path.length - 1] ?? '',
124
+ isExpanded: false,
125
+ children: [],
126
+ serverChildrenCount: row.serverChildrenCount,
62
127
  });
128
+ });
129
+
130
+ // The node for a path (length >= 1), creating auto parents for segments that have no row.
131
+ const ensureNode = (path: string[]): GridRowId => {
132
+ const key = pathKeyOf(path);
133
+ const existing = pathLookup.get(key);
134
+ if (existing !== undefined) return existing;
135
+
136
+ const id = autoParentIdOf(key);
137
+ const label = path[path.length - 1];
138
+ const node: GridTreeNode = {
139
+ id,
140
+ parentId: null,
141
+ depth: path.length - 1,
142
+ groupingKey: label,
143
+ isExpanded: false,
144
+ children: [],
145
+ label,
146
+ };
147
+ treeNodes.set(id, node);
148
+ pathLookup.set(key, id);
149
+ syntheticIds.add(id);
150
+ const groupRow: GridRowModel = { id };
151
+ groupingRows.push(groupRow);
152
+ rowLookup.set(id, groupRow);
153
+
154
+ if (path.length === 1) {
155
+ rootIds.push(id);
156
+ } else {
157
+ const parentId = ensureNode(path.slice(0, -1));
158
+ node.parentId = parentId;
159
+ treeNodes.get(parentId)?.children?.push(id);
160
+ }
161
+ return id;
162
+ };
163
+
164
+ // Pass 2: link every row to its parent, in row order.
165
+ rows.forEach((_, index) => {
166
+ const id = ids[index];
167
+ const path = paths[index];
168
+ if (path.length <= 1) {
169
+ rootIds.push(id);
170
+ return;
171
+ }
172
+ const parentId = ensureNode(path.slice(0, -1));
173
+ const node = treeNodes.get(id);
174
+ if (node) node.parentId = parentId;
175
+ treeNodes.get(parentId)?.children?.push(id);
176
+ });
177
+
178
+ if (emptyPathIds.length > 0) {
179
+ issues.push(`[OpenGridX] treeData: getTreeDataPath returned an empty path for row(s) ${formatIds(emptyPathIds)}. They are shown as top-level rows.`);
180
+ }
181
+ duplicatePaths.forEach((clash, key) => {
182
+ issues.push(`[OpenGridX] treeData: rows ${formatIds(clash)} have the same path ${key}. Rows under that path are attached to row ${JSON.stringify(clash[0])}.`);
183
+ });
184
+
185
+ return { treeNodes, rootIds, groupingRows, syntheticIds, rowLookup, issues };
186
+ }
63
187
 
64
- rows.forEach(row => {
65
- const id = getRowId(row);
66
- const path = getTreeDataPath(row);
67
-
68
- if (path.length === 1) {
69
- roots.push(id);
70
- return;
71
- }
72
-
73
- const ensureAncestors = (childPath: string[]) => {
74
- if (childPath.length <= 1) return;
75
-
76
- const parentPath = childPath.slice(0, -1);
77
- const parentPathKey = getPathKey(parentPath);
78
- let parentId = pathLookup.get(parentPathKey);
79
-
80
- if (parentId === undefined) {
81
- parentId = `auto-group-${parentPathKey}`;
82
-
83
- if (!nodes.has(parentId)) {
84
- const groupLabel = parentPath[parentPath.length - 1];
85
- nodes.set(parentId, {
86
- id: parentId,
87
- parentId: null,
88
- depth: parentPath.length - 1,
89
- groupingKey: groupLabel,
90
- isExpanded: false,
91
- children: [],
92
- label: groupLabel
93
- });
94
- pathLookup.set(parentPathKey, parentId);
95
-
96
- const groupRow = {
97
- id: parentId,
98
- name: groupLabel,
99
- _isGroupRow: true
100
- } as unknown as GridRowModel;
101
- groupRows.push(groupRow);
102
-
103
- if (parentPath.length === 1) {
104
- roots.push(parentId);
105
- }
106
-
107
- ensureAncestors(parentPath);
108
- }
109
- }
110
-
111
- return parentId;
112
- };
113
-
114
- const parentId = ensureAncestors(path);
115
-
116
- if (parentId !== undefined) {
117
- const parentNode = nodes.get(parentId);
118
- const childNode = nodes.get(id);
188
+ interface TreeVisibility {
189
+ /** Nodes shown under the filter: rows that match, and the ancestors of rows that match. */
190
+ visible: Set<GridRowId>;
191
+ /** Each node's children that are visible. */
192
+ visibleChildren: Map<GridRowId, GridRowId[]>;
193
+ /** Visible consumer rows below each node, at any depth (auto-created parents are not counted). */
194
+ descendantCounts: Map<GridRowId, number>;
195
+ }
119
196
 
120
- if (parentNode && childNode) {
121
- childNode.parentId = parentId;
122
- parentNode.children = parentNode.children || [];
123
- if (!parentNode.children.includes(id)) {
124
- parentNode.children.push(id);
125
- }
126
- }
197
+ function computeVisibility(build: TreeBuild, rowFilter: ((row: GridRowModel) => boolean) | null): TreeVisibility {
198
+ const { treeNodes, rootIds, syntheticIds, rowLookup } = build;
199
+ const visible = new Set<GridRowId>();
200
+ const visibleChildren = new Map<GridRowId, GridRowId[]>();
201
+ const descendantCounts = new Map<GridRowId, number>();
202
+
203
+ const visit = (id: GridRowId): boolean => {
204
+ const node = treeNodes.get(id);
205
+ if (!node) return false;
206
+ const shownChildren: GridRowId[] = [];
207
+ let count = 0;
208
+ for (const childId of node.children ?? []) {
209
+ if (visit(childId)) {
210
+ shownChildren.push(childId);
211
+ count += (syntheticIds.has(childId) ? 0 : 1) + (descendantCounts.get(childId) ?? 0);
127
212
  }
128
- });
213
+ }
214
+ visibleChildren.set(id, shownChildren);
215
+ descendantCounts.set(id, count);
216
+
217
+ // An auto-created parent has no data of its own: it is shown only for the rows under it.
218
+ const row = rowLookup.get(id);
219
+ const selfMatch = !syntheticIds.has(id) && row !== undefined && (rowFilter ? rowFilter(row) : true);
220
+ const isVisible = selfMatch || shownChildren.length > 0;
221
+ if (isVisible) visible.add(id);
222
+ return isVisible;
223
+ };
224
+
225
+ rootIds.forEach(visit);
226
+ return { visible, visibleChildren, descendantCounts };
227
+ }
129
228
 
130
- groupRows.forEach(groupRow => {
131
- const groupId = groupRow.id;
132
- const node = nodes.get(groupId);
133
- if (!node) return;
134
-
135
- const nodePathKey = (groupId as string).replace('auto-group-', '');
136
- const nodePath = nodePathKey.split('/');
137
-
138
- if (nodePath.length > 1) {
139
- const parentPath = nodePath.slice(0, -1);
140
- const parentPathKey = getPathKey(parentPath);
141
- const parentId = pathLookup.get(parentPathKey);
142
-
143
- if (parentId && nodes.has(parentId)) {
144
- node.parentId = parentId;
145
- const parentNode = nodes.get(parentId);
146
- if (parentNode) {
147
- parentNode.children = parentNode.children || [];
148
- if (!parentNode.children.includes(groupId)) {
149
- parentNode.children.push(groupId);
150
- }
151
- }
152
- }
153
- }
154
- });
229
+ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>) {
230
+ const {
231
+ rows,
232
+ getRowId,
233
+ getTreeDataPath,
234
+ treeData = false,
235
+ defaultGroupingExpansionDepth = 0,
236
+ filterModel,
237
+ sortModel,
238
+ onRowExpansionChange,
239
+ columnLookup,
240
+ filterMode = 'client',
241
+ sortingMode = 'client',
242
+ } = props;
155
243
 
156
- return { treeNodes: nodes, rootIds: roots, groupingRows: groupRows };
244
+ const isActive = treeData && Boolean(getTreeDataPath);
157
245
 
246
+ const build = useMemo<TreeBuild>(() => {
247
+ if (!treeData || !getTreeDataPath) return EMPTY_BUILD;
248
+ return buildTree(rows, getRowId, getTreeDataPath);
158
249
  }, [rows, getTreeDataPath, treeData, getRowId]);
159
250
 
160
- const initialExpandedIds = useMemo(() => {
161
- if (!treeData) return new Set<GridRowId>();
162
-
163
- const initialExpanded = new Set<GridRowId>();
164
- treeNodes.forEach(node => {
165
- if (node.children && node.children.length > 0) {
166
- if (defaultGroupingExpansionDepth === -1 || node.depth < defaultGroupingExpansionDepth) {
167
- initialExpanded.add(node.id);
168
- }
169
- }
170
- });
171
- return initialExpanded;
172
- }, [treeNodes, treeData, defaultGroupingExpansionDepth]);
251
+ const { treeNodes, rootIds, groupingRows, syntheticIds, rowLookup } = build;
173
252
 
174
- const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
253
+ // Development warnings, logged once per distinct problem rather than on every render.
254
+ const issuesKey = build.issues.join('\n');
255
+ useEffect(() => {
256
+ if (process.env.NODE_ENV === 'production' || !issuesKey) return;
257
+ issuesKey.split('\n').forEach(message => console.warn(message));
258
+ }, [issuesKey]);
175
259
 
260
+ const isMissingPathGetter = treeData && !getTreeDataPath;
176
261
  useEffect(() => {
177
- if (initialExpandedIds.size > 0) {
178
- setExpandedGroupIds(initialExpandedIds);
179
- }
180
- }, [initialExpandedIds]);
262
+ if (process.env.NODE_ENV === 'production' || !isMissingPathGetter) return;
263
+ console.warn('[OpenGridX] treeData is set without getTreeDataPath, so the rows are shown as a flat list. Pass getTreeDataPath={(row) => row.path} to build the tree.');
264
+ }, [isMissingPathGetter]);
265
+
266
+ const rowFilter = useMemo(
267
+ () => (filterMode === 'server' ? null : createRowFilter(filterModel, columnLookup)),
268
+ [filterMode, filterModel, columnLookup]
269
+ );
270
+
271
+ const visibility = useMemo(() => computeVisibility(build, rowFilter), [build, rowFilter]);
272
+
273
+ // Same model as useRowGrouping: depth-based default plus user overrides keyed by config.
274
+ // Rebuilding the tree (row edits, lazily loaded children) keeps the user's choices.
275
+ const configKey = `${treeData}|${defaultGroupingExpansionDepth}`;
276
+ const [expansionState, setExpansionState] = useState<ExpansionOverrides>(() => ({
277
+ configKey,
278
+ overrides: new Map(),
279
+ }));
280
+ const overrides = expansionState.configKey === configKey ? expansionState.overrides : EMPTY_OVERRIDES;
281
+
282
+ // A lazy server node (serverChildrenCount > 0, children not loaded yet) is expandable too, so
283
+ // the default depth expands it and useServerTreeChildren loads its children.
284
+ const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => (
285
+ ((node?.children?.length ?? 0) > 0 || (node?.serverChildrenCount ?? 0) > 0) &&
286
+ (defaultGroupingExpansionDepth === -1 || (node?.depth ?? 0) < defaultGroupingExpansionDepth)
287
+ ), [defaultGroupingExpansionDepth]);
288
+
289
+ const expandedGroupIds = useMemo<Set<GridRowId>>(() => {
290
+ const ids = new Set<GridRowId>();
291
+ if (!isActive) return ids;
292
+ treeNodes.forEach((node, id) => {
293
+ // A lazily loaded node can be expanded before it has any children, so an
294
+ // explicit override counts even when children is still empty.
295
+ if (overrides.get(id) ?? isDefaultExpanded(node)) ids.add(id);
296
+ });
297
+ return ids;
298
+ }, [isActive, treeNodes, overrides, isDefaultExpanded]);
181
299
 
182
300
  const toggleExpansion = useCallback((id: GridRowId) => {
183
- setExpandedGroupIds(prev => {
184
- const next = new Set(prev);
185
- const isExpanding = !next.has(id);
186
-
187
- if (next.has(id)) {
188
- next.delete(id);
189
- } else {
190
- next.add(id);
191
- }
192
-
193
- if (isExpanding && onRowExpansionChange) {
194
- const node = treeNodes.get(id);
195
- if (node) {
196
- onRowExpansionChange(node);
197
- }
198
- }
199
-
200
- return next;
301
+ const isExpanding = !expandedGroupIds.has(id);
302
+ setExpansionState(prev => {
303
+ const base = prev.configKey === configKey ? prev.overrides : EMPTY_OVERRIDES;
304
+ const next = new Map(base);
305
+ next.set(id, isExpanding);
306
+ return { configKey, overrides: next };
201
307
  });
202
- }, [treeNodes, onRowExpansionChange]);
203
-
204
- const getVisibleRows = useCallback(() => {
205
-
206
- if (!treeData) return null;
207
-
208
- const rowLookup = new Map<GridRowId, GridRowModel>();
209
- rows.forEach(r => rowLookup.set(getRowId(r), r));
210
- groupingRows.forEach(r => rowLookup.set(r.id, r));
211
-
212
- const matchesFilter = (row: GridRowModel) => {
213
- return isRowMatchingFilter(row, filterModel!);
214
- };
215
-
216
- const filterCache = new Map<GridRowId, boolean>();
217
-
218
- const isNodeVisible = (id: GridRowId): boolean => {
219
- if (filterCache.has(id)) return filterCache.get(id)!;
220
-
221
- const row = rowLookup.get(id);
222
- if (!row) return false;
223
-
224
- const selfMatch = matchesFilter(row);
225
- if (selfMatch) {
226
- filterCache.set(id, true);
227
- return true;
228
- }
229
-
308
+ // Called outside the state updater: updaters may run twice, and this can trigger a server fetch.
309
+ if (isExpanding && onRowExpansionChange) {
230
310
  const node = treeNodes.get(id);
231
- if (node && node.children) {
232
- const childMatch = node.children.some(childId => isNodeVisible(childId));
233
- if (childMatch) {
234
- filterCache.set(id, true);
235
- return true;
236
- }
237
- }
238
-
239
- filterCache.set(id, false);
240
- return false;
241
- };
242
-
243
- const sortNodes = (aId: GridRowId, bId: GridRowId) => {
244
- if (!sortModel || sortModel.length === 0) return 0;
245
- const rowA = rowLookup.get(aId);
246
- const rowB = rowLookup.get(bId);
247
- if (!rowA || !rowB) return 0;
248
-
249
- for (const sortItem of sortModel) {
250
- const valA = rowA[sortItem.field];
251
- const valB = rowB[sortItem.field];
252
- const comp = compareValues(valA, valB, sortItem.sort);
253
- if (comp !== 0) return comp;
311
+ if (node) onRowExpansionChange(node);
312
+ }
313
+ }, [expandedGroupIds, configKey, treeNodes, onRowExpansionChange]);
314
+
315
+ const isClientSort = sortingMode !== 'server' && Boolean(sortModel && sortModel.length > 0);
316
+
317
+ /**
318
+ * The rows to render, in order. `labelField` is the column that shows the hierarchy (toggle,
319
+ * indent and the labels of auto-created parents): sorting by it orders auto-created parents by
320
+ * their label, since they have no cell values of their own.
321
+ */
322
+ const getVisibleRows = useCallback((options?: GridHierarchyVisibleRowsOptions): GridRowModel[] | null => {
323
+ if (!isActive) return null;
324
+ const labelField = options?.labelField;
325
+ const expandAll = options?.expandAll ?? false;
326
+
327
+ const { visible, visibleChildren } = visibility;
328
+ const activeSortModel = isClientSort && sortModel ? sortModel : [];
329
+
330
+ const readSortValue = (id: GridRowId, sortItem: GridSortItem): GridSortValue => {
331
+ if (syntheticIds.has(id)) {
332
+ return { value: sortItem.field === labelField ? treeNodes.get(id)?.label : undefined };
254
333
  }
255
- return 0;
334
+ const row = rowLookup.get(id);
335
+ const colDef = columnLookup?.byField.get(sortItem.field);
336
+ return { value: row ? getCellValue(row, sortItem.field, colDef) : undefined, type: colDef?.type };
256
337
  };
257
338
 
258
339
  const result: GridRowModel[] = [];
259
-
260
340
  const seenIds = new Set<GridRowId>();
261
341
 
262
- const traverse = (ids: GridRowId[]) => {
263
-
264
- const visibleIds = ids.filter(id => isNodeVisible(id));
265
-
266
- visibleIds.sort(sortNodes);
267
-
268
- visibleIds.forEach(id => {
269
- if (seenIds.has(id)) return;
270
-
342
+ const traverse = (ids: readonly GridRowId[]) => {
343
+ const shown = ids.filter(id => visible.has(id));
344
+ const ordered = activeSortModel.length > 0 ? sortItemsBySortModel(shown, activeSortModel, readSortValue) : shown;
345
+ for (const id of ordered) {
346
+ if (seenIds.has(id)) continue;
271
347
  const row = rowLookup.get(id);
272
- const node = treeNodes.get(id);
273
-
274
- if (row && node) {
275
- seenIds.add(id);
276
-
277
- const enhancedRow = {
278
- ...row,
279
- _treeDepth: node.depth,
280
- _isExpanded: expandedGroupIds.has(id),
281
- _hasChildren: Boolean((node.children && node.children.length > 0) || (node.serverChildrenCount && node.serverChildrenCount > 0)),
282
- _descendantCount: node.children ? node.children.length : 0
283
- };
284
- result.push(enhancedRow);
285
-
286
- if (expandedGroupIds.has(id) && node.children && node.children.length > 0) {
287
- traverse(node.children);
288
- }
348
+ if (!row) continue;
349
+ seenIds.add(id);
350
+ // Hierarchy info travels in rowMetaMap; the consumer's row object is passed through unchanged.
351
+ result.push(row);
352
+ const children = visibleChildren.get(id);
353
+ if (children && children.length > 0 && (expandAll || expandedGroupIds.has(id))) {
354
+ traverse(children);
289
355
  }
290
- });
356
+ }
291
357
  };
292
358
 
293
359
  traverse(rootIds);
294
360
  return result;
295
-
296
- }, [treeData, rows, groupingRows, rootIds, treeNodes, expandedGroupIds, filterModel, sortModel, getRowId]);
361
+ }, [isActive, visibility, isClientSort, sortModel, syntheticIds, treeNodes, rowLookup, columnLookup, rootIds, expandedGroupIds]);
297
362
 
298
363
  const isGroupExpanded = useCallback((id: GridRowId) => expandedGroupIds.has(id), [expandedGroupIds]);
299
364
  const getNode = useCallback((id: GridRowId) => treeNodes.get(id), [treeNodes]);
@@ -314,21 +379,24 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
314
379
 
315
380
  const rowMetaMap = useMemo<Map<GridRowId, GridRowMeta>>(() => {
316
381
  const map = new Map<GridRowId, GridRowMeta>();
317
- const groupRowIds = new Set(groupingRows.map(r => r.id));
382
+ const { visibleChildren, descendantCounts } = visibility;
318
383
  treeNodes.forEach((node, id) => {
319
- map.set(id, {
320
- hasChildren: Boolean(
321
- (node.children && node.children.length > 0) ||
322
- (node.serverChildrenCount && node.serverChildrenCount > 0)
323
- ),
384
+ // A lazy node whose children are not loaded yet still has children on the server.
385
+ const isUnloadedLazyNode = (node.children?.length ?? 0) === 0 && (node.serverChildrenCount ?? 0) > 0;
386
+ const isSynthetic = syntheticIds.has(id);
387
+ const meta: GridRowMeta = {
388
+ // Based on the children the filter leaves visible, so no toggle expands to nothing.
389
+ hasChildren: (visibleChildren.get(id)?.length ?? 0) > 0 || isUnloadedLazyNode,
324
390
  treeDepth: node.depth,
325
- descendantCount: node.children ? node.children.length : undefined,
326
- isGroupRow: groupRowIds.has(id),
391
+ descendantCount: isUnloadedLazyNode ? undefined : (descendantCounts.get(id) ?? 0),
392
+ isGroupRow: isSynthetic,
327
393
  isExpanded: expandedGroupIds.has(id),
328
- });
394
+ };
395
+ if (isSynthetic) meta.groupLabel = node.label;
396
+ map.set(id, meta);
329
397
  });
330
398
  return map;
331
- }, [treeNodes, groupingRows, expandedGroupIds]);
399
+ }, [treeNodes, syntheticIds, visibility, expandedGroupIds]);
332
400
 
333
401
  return useMemo(() => ({
334
402
  treeNodes,