@opencorestack/opengridx 0.1.9 → 1.0.1
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/CHANGELOG.md +51 -0
- package/README.md +162 -19
- package/dist/index.d.ts +60 -41
- package/dist/opengridx.es.js +5045 -4549
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +11 -11
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +17 -7
- package/docs/README.md +14 -0
- package/docs/components/aggregation-footer.md +48 -0
- package/docs/components/datagrid.md +8 -3
- package/docs/components/empty-state.md +50 -0
- package/docs/components/error-overlay.md +40 -0
- package/docs/features/editing-reordering.md +11 -7
- package/docs/features/filtering.md +10 -8
- package/docs/features/selection.md +2 -1
- package/docs/features/sorting-pagination.md +3 -1
- package/docs/features/state-persistence.md +1 -2
- package/docs/roadmap.md +20 -1
- package/lib/components/Cell/Cell.tsx +9 -10
- package/lib/components/Cell/GridEditInputCell.tsx +4 -4
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +132 -0
- package/lib/components/DataGrid/DataGrid.tsx +336 -1018
- package/lib/components/DataGrid/GridAggregationFooter.tsx +68 -0
- package/lib/components/DataGrid/GridEmptyState.tsx +16 -0
- package/lib/components/DataGrid/GridErrorOverlay.tsx +33 -0
- package/lib/components/DataGrid/GridListView.tsx +116 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +98 -0
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +124 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +202 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +18 -10
- package/lib/components/Header/ColumnMenu.tsx +2 -2
- package/lib/components/Header/Header.tsx +4 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Row/Row.tsx +10 -10
- package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
- package/lib/components/Toolbar/GridToolbar.tsx +6 -0
- package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
- package/lib/hooks/core/useDataGrid.ts +14 -34
- package/lib/hooks/core/useGridColumns.tsx +228 -0
- package/lib/hooks/core/useGridControlledState.ts +192 -0
- package/lib/hooks/core/useGridRowPipeline.ts +109 -0
- package/lib/hooks/core/useGridScrollSync.ts +52 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +63 -0
- package/lib/hooks/core/useGridVirtualization.ts +186 -0
- package/lib/hooks/core/useGridVisibleRows.ts +66 -0
- package/lib/hooks/core/useLayout.ts +0 -6
- package/lib/hooks/features/useAggregation.ts +7 -7
- package/lib/hooks/features/useGridDataSource.ts +1 -1
- package/lib/hooks/features/useGridEditing.ts +31 -32
- package/lib/hooks/features/usePivot.test.ts +202 -0
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/useColumnReorder.ts +1 -1
- package/lib/hooks/useRowGrouping.ts +14 -14
- package/lib/hooks/useTreeData.ts +1 -1
- package/lib/state/types.ts +1 -1
- package/lib/state/useGridStateStorage.ts +1 -1
- package/lib/types/index.ts +48 -32
- package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
- package/lib/utils/export/index.ts +22 -17
- package/lib/utils/filtering/index.ts +20 -20
- package/lib/utils/sorting/index.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { GridRowModel, GridColDef } from '../../types';
|
|
3
|
+
import type { LayoutResult } from './useLayout';
|
|
4
|
+
|
|
5
|
+
export interface UseGridVirtualizationParams<R extends GridRowModel> {
|
|
6
|
+
layout: LayoutResult<R>;
|
|
7
|
+
scrollTop: number;
|
|
8
|
+
scrollLeft: number;
|
|
9
|
+
viewportWidth: number;
|
|
10
|
+
viewportHeight: number;
|
|
11
|
+
autoHeight: boolean;
|
|
12
|
+
rowReordering: boolean;
|
|
13
|
+
hasDetailPanel: boolean;
|
|
14
|
+
checkboxSelection: boolean;
|
|
15
|
+
pinCheckboxColumn: boolean;
|
|
16
|
+
pinExpandColumn: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel> {
|
|
20
|
+
renderContext: {
|
|
21
|
+
firstRowIndex: number;
|
|
22
|
+
lastRowIndex: number;
|
|
23
|
+
firstColumnIndex: number;
|
|
24
|
+
lastColumnIndex: number;
|
|
25
|
+
};
|
|
26
|
+
offsetTop: number;
|
|
27
|
+
offsetLeft: number;
|
|
28
|
+
totalHeight: number;
|
|
29
|
+
pinnedTopHeight: number;
|
|
30
|
+
pinnedBottomHeight: number;
|
|
31
|
+
totalWidth: number;
|
|
32
|
+
rowHeights: number[];
|
|
33
|
+
cumulativeHeights: number[];
|
|
34
|
+
virtualColumns: (GridColDef<R> & { width: number })[];
|
|
35
|
+
columnMetrics: {
|
|
36
|
+
leftPinnedWidth: number;
|
|
37
|
+
rightPinnedWidth: number;
|
|
38
|
+
unpinnedAccWidths: number[];
|
|
39
|
+
unpinnedCols: { field: string; width: number }[];
|
|
40
|
+
totalSpecialsWidth: number;
|
|
41
|
+
pinnedSpecialsWidth: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useGridVirtualization<R extends GridRowModel>(
|
|
46
|
+
params: UseGridVirtualizationParams<R>
|
|
47
|
+
): GridVirtualizationResult<R> {
|
|
48
|
+
const {
|
|
49
|
+
layout,
|
|
50
|
+
scrollTop,
|
|
51
|
+
scrollLeft,
|
|
52
|
+
viewportWidth: rawViewportWidth,
|
|
53
|
+
viewportHeight: rawViewportHeight,
|
|
54
|
+
autoHeight,
|
|
55
|
+
rowReordering,
|
|
56
|
+
hasDetailPanel,
|
|
57
|
+
checkboxSelection,
|
|
58
|
+
pinCheckboxColumn,
|
|
59
|
+
pinExpandColumn,
|
|
60
|
+
} = params;
|
|
61
|
+
|
|
62
|
+
return useMemo<GridVirtualizationResult<R>>(() => {
|
|
63
|
+
const {
|
|
64
|
+
unpinnedAccWidths,
|
|
65
|
+
unpinnedColsWithWidth,
|
|
66
|
+
leftPinnedCols,
|
|
67
|
+
rightPinnedCols,
|
|
68
|
+
totalWidth,
|
|
69
|
+
leftWidth,
|
|
70
|
+
rightWidth,
|
|
71
|
+
unpinnedTotalWidth,
|
|
72
|
+
systemColumnsWidth,
|
|
73
|
+
cumulativeHeights,
|
|
74
|
+
pinnedTopHeight,
|
|
75
|
+
pinnedBottomHeight,
|
|
76
|
+
unpinnedRowsHeight,
|
|
77
|
+
rowHeights,
|
|
78
|
+
unpinnedRowsLength,
|
|
79
|
+
} = layout;
|
|
80
|
+
|
|
81
|
+
const viewportWidth = rawViewportWidth || 1000;
|
|
82
|
+
const viewportHeight = autoHeight
|
|
83
|
+
? pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + 50
|
|
84
|
+
: rawViewportHeight || 600;
|
|
85
|
+
|
|
86
|
+
const overscanCols = 6;
|
|
87
|
+
const visibleLocalStart = Math.max(0, scrollLeft - leftWidth);
|
|
88
|
+
const visibleLocalEnd = scrollLeft + viewportWidth - leftWidth;
|
|
89
|
+
|
|
90
|
+
let firstUnpinnedIndex = 0;
|
|
91
|
+
let lastUnpinnedIndex = unpinnedColsWithWidth.length - 1;
|
|
92
|
+
|
|
93
|
+
let low = 0, high = unpinnedAccWidths.length - 1;
|
|
94
|
+
while (low <= high) {
|
|
95
|
+
const mid = (low + high) >>> 1;
|
|
96
|
+
if (unpinnedAccWidths[mid] < visibleLocalStart) { low = mid + 1; } else { high = mid - 1; }
|
|
97
|
+
}
|
|
98
|
+
firstUnpinnedIndex = Math.max(0, low - overscanCols);
|
|
99
|
+
|
|
100
|
+
for (let i = firstUnpinnedIndex; i < unpinnedAccWidths.length; i++) {
|
|
101
|
+
if ((i === 0 ? 0 : unpinnedAccWidths[i - 1]) > visibleLocalEnd) {
|
|
102
|
+
lastUnpinnedIndex = Math.min(unpinnedColsWithWidth.length - 1, i + overscanCols);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
lastUnpinnedIndex = Math.min(unpinnedColsWithWidth.length - 1, i + overscanCols);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const leftSpacerWidth = firstUnpinnedIndex > 0 ? unpinnedAccWidths[firstUnpinnedIndex - 1] : 0;
|
|
109
|
+
const rightSpacerWidth = unpinnedTotalWidth - unpinnedAccWidths[lastUnpinnedIndex];
|
|
110
|
+
|
|
111
|
+
const virtualColumns: (GridColDef<R> & { width: number })[] = [
|
|
112
|
+
...leftPinnedCols,
|
|
113
|
+
...(leftSpacerWidth > 0 ? [{ field: '__spacer_left__', width: leftSpacerWidth, isSpacer: true }] : []),
|
|
114
|
+
...unpinnedColsWithWidth.slice(firstUnpinnedIndex, lastUnpinnedIndex + 1),
|
|
115
|
+
...(rightSpacerWidth > 0 ? [{ field: '__spacer_right__', width: rightSpacerWidth, isSpacer: true }] : []),
|
|
116
|
+
...rightPinnedCols,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const hasHorizontalScroll = viewportWidth > 0 && totalWidth > viewportWidth;
|
|
120
|
+
const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
|
|
121
|
+
const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
|
|
122
|
+
|
|
123
|
+
const overscanRows = 5;
|
|
124
|
+
let firstRowIndex = 0;
|
|
125
|
+
let lastRowIndex = unpinnedRowsLength - 1;
|
|
126
|
+
let offsetTop = 0;
|
|
127
|
+
|
|
128
|
+
let rLow = 0, rHigh = cumulativeHeights.length - 1;
|
|
129
|
+
while (rLow <= rHigh) {
|
|
130
|
+
const mid = (rLow + rHigh) >>> 1;
|
|
131
|
+
if (cumulativeHeights[mid] < scrollTop) { rLow = mid + 1; } else { rHigh = mid - 1; }
|
|
132
|
+
}
|
|
133
|
+
firstRowIndex = Math.max(0, rLow - overscanRows);
|
|
134
|
+
offsetTop = firstRowIndex > 0 ? cumulativeHeights[firstRowIndex - 1] : 0;
|
|
135
|
+
|
|
136
|
+
for (let i = firstRowIndex; i < cumulativeHeights.length; i++) {
|
|
137
|
+
if (cumulativeHeights[i] >= scrollTop + viewportHeight) {
|
|
138
|
+
lastRowIndex = Math.min(unpinnedRowsLength - 1, i + overscanRows);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pinnedSpecialsWidth =
|
|
144
|
+
(rowReordering ? 48 : 0) +
|
|
145
|
+
(hasDetailPanel && pinExpandColumn ? 48 : 0) +
|
|
146
|
+
(checkboxSelection && pinCheckboxColumn ? 48 : 0);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
renderContext: {
|
|
150
|
+
firstRowIndex,
|
|
151
|
+
lastRowIndex,
|
|
152
|
+
firstColumnIndex: firstUnpinnedIndex,
|
|
153
|
+
lastColumnIndex: lastUnpinnedIndex,
|
|
154
|
+
},
|
|
155
|
+
offsetTop,
|
|
156
|
+
offsetLeft: 0,
|
|
157
|
+
totalHeight,
|
|
158
|
+
pinnedTopHeight,
|
|
159
|
+
pinnedBottomHeight,
|
|
160
|
+
totalWidth,
|
|
161
|
+
rowHeights,
|
|
162
|
+
cumulativeHeights,
|
|
163
|
+
virtualColumns,
|
|
164
|
+
columnMetrics: {
|
|
165
|
+
leftPinnedWidth: leftWidth,
|
|
166
|
+
rightPinnedWidth: rightWidth,
|
|
167
|
+
unpinnedAccWidths,
|
|
168
|
+
unpinnedCols: unpinnedColsWithWidth,
|
|
169
|
+
totalSpecialsWidth: systemColumnsWidth,
|
|
170
|
+
pinnedSpecialsWidth,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}, [
|
|
174
|
+
layout,
|
|
175
|
+
scrollTop,
|
|
176
|
+
scrollLeft,
|
|
177
|
+
rawViewportWidth,
|
|
178
|
+
rawViewportHeight,
|
|
179
|
+
autoHeight,
|
|
180
|
+
rowReordering,
|
|
181
|
+
hasDetailPanel,
|
|
182
|
+
checkboxSelection,
|
|
183
|
+
pinCheckboxColumn,
|
|
184
|
+
pinExpandColumn,
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { GridRowModel, GridRowId } from '../../types';
|
|
3
|
+
|
|
4
|
+
interface RenderContext {
|
|
5
|
+
firstRowIndex: number;
|
|
6
|
+
lastRowIndex: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseGridVisibleRowsParams<R extends GridRowModel> {
|
|
10
|
+
renderContext: RenderContext;
|
|
11
|
+
pinnedTopRows: R[];
|
|
12
|
+
pinnedBottomRows: R[];
|
|
13
|
+
paginatedUnpinnedRows: R[];
|
|
14
|
+
sortedUnpinnedRows: R[];
|
|
15
|
+
pagination: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GridVisibleRow<R extends GridRowModel> {
|
|
19
|
+
row: R;
|
|
20
|
+
rowIndex: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useGridVisibleRows<R extends GridRowModel>(
|
|
24
|
+
params: UseGridVisibleRowsParams<R>
|
|
25
|
+
): GridVisibleRow<R>[] {
|
|
26
|
+
const {
|
|
27
|
+
renderContext,
|
|
28
|
+
pinnedTopRows,
|
|
29
|
+
pinnedBottomRows,
|
|
30
|
+
paginatedUnpinnedRows,
|
|
31
|
+
sortedUnpinnedRows,
|
|
32
|
+
pagination,
|
|
33
|
+
} = params;
|
|
34
|
+
|
|
35
|
+
return useMemo<GridVisibleRow<R>[]>(() => {
|
|
36
|
+
const { firstRowIndex, lastRowIndex } = renderContext;
|
|
37
|
+
const centerRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
|
|
38
|
+
const topPinnedCount = pinnedTopRows.length;
|
|
39
|
+
|
|
40
|
+
const topPinned = pinnedTopRows.map((row, index) => ({ row, rowIndex: index }));
|
|
41
|
+
const bottomPinned = pinnedBottomRows.map((row, index) => ({
|
|
42
|
+
row,
|
|
43
|
+
rowIndex: topPinnedCount + centerRows.length + index,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const centerStartIndex = Math.max(0, firstRowIndex - topPinnedCount);
|
|
47
|
+
const centerEndIndex = Math.min(centerRows.length, lastRowIndex - topPinnedCount + 1);
|
|
48
|
+
|
|
49
|
+
const centerVisible = centerRows
|
|
50
|
+
.slice(centerStartIndex, centerEndIndex)
|
|
51
|
+
.map((row, index) => ({
|
|
52
|
+
row,
|
|
53
|
+
rowIndex: topPinnedCount + centerStartIndex + index,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const combined = [...topPinned, ...centerVisible, ...bottomPinned];
|
|
57
|
+
|
|
58
|
+
// Deduplication guard: a row ID in both pinnedRows and rows would cause React key collisions
|
|
59
|
+
const seenIds = new Set<GridRowId>();
|
|
60
|
+
return combined.filter(item => {
|
|
61
|
+
if (seenIds.has(item.row.id)) return false;
|
|
62
|
+
seenIds.add(item.row.id);
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
}, [renderContext, pinnedTopRows, pinnedBottomRows, paginatedUnpinnedRows, sortedUnpinnedRows, pagination]);
|
|
66
|
+
}
|
|
@@ -79,9 +79,6 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
|
|
|
79
79
|
checkboxSelection,
|
|
80
80
|
hasDetailPanel,
|
|
81
81
|
rowReordering,
|
|
82
|
-
pinCheckboxColumn,
|
|
83
|
-
pinExpandColumn,
|
|
84
|
-
autoHeight,
|
|
85
82
|
paginationMode,
|
|
86
83
|
isLoading,
|
|
87
84
|
pageSize,
|
|
@@ -344,9 +341,6 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
|
|
|
344
341
|
checkboxSelection,
|
|
345
342
|
hasDetailPanel,
|
|
346
343
|
rowReordering,
|
|
347
|
-
pinCheckboxColumn,
|
|
348
|
-
pinExpandColumn,
|
|
349
|
-
autoHeight,
|
|
350
344
|
paginationMode,
|
|
351
345
|
isLoading,
|
|
352
346
|
pageSize,
|
|
@@ -11,15 +11,15 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
export type BuiltInAggFn = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'unique';
|
|
13
13
|
|
|
14
|
-
const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values:
|
|
14
|
+
const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: unknown[]) => unknown> = {
|
|
15
15
|
sum: (values) => {
|
|
16
16
|
const nums = values.filter((v) => v != null && !isNaN(Number(v)));
|
|
17
|
-
return nums.reduce((acc, v) => acc + Number(v), 0);
|
|
17
|
+
return nums.reduce((acc: number, v) => acc + Number(v), 0);
|
|
18
18
|
},
|
|
19
19
|
avg: (values) => {
|
|
20
20
|
const nums = values.filter((v) => v != null && !isNaN(Number(v)));
|
|
21
21
|
if (nums.length === 0) return null;
|
|
22
|
-
return nums.reduce((acc, v) => acc + Number(v), 0) / nums.length;
|
|
22
|
+
return nums.reduce((acc: number, v) => acc + Number(v), 0) / nums.length;
|
|
23
23
|
},
|
|
24
24
|
count: (values) => values.filter((v) => v != null).length,
|
|
25
25
|
min: (values) => {
|
|
@@ -33,7 +33,7 @@ const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: any[]) => any> = {
|
|
|
33
33
|
unique: (values) => new Set(values.filter((v) => v != null)).size,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export function formatAggregationValue(value:
|
|
36
|
+
export function formatAggregationValue(value: unknown, fnName: string): string {
|
|
37
37
|
if (value == null) return '—';
|
|
38
38
|
if (typeof value === 'number') {
|
|
39
39
|
if (fnName === 'avg') {
|
|
@@ -57,7 +57,7 @@ export interface UseAggregationParams<R extends GridRowModel> {
|
|
|
57
57
|
export interface UseAggregationReturn {
|
|
58
58
|
aggregationResult: GridAggregationResult;
|
|
59
59
|
isLoading: boolean;
|
|
60
|
-
error:
|
|
60
|
+
error: unknown;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function useAggregation<R extends GridRowModel>(
|
|
@@ -83,7 +83,7 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
83
83
|
console.warn(`[useAggregation] Unknown aggregation function: "${fnName}"`);
|
|
84
84
|
continue;
|
|
85
85
|
}
|
|
86
|
-
const values = rows.map((row) => (row as
|
|
86
|
+
const values = rows.map((row) => (row as GridRowModel)[field]);
|
|
87
87
|
result[field] = fn(values);
|
|
88
88
|
}
|
|
89
89
|
return result;
|
|
@@ -91,7 +91,7 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
91
91
|
|
|
92
92
|
const [serverResult, setServerResult] = useState<GridAggregationResult>({});
|
|
93
93
|
const [isLoading, setIsLoading] = useState(false);
|
|
94
|
-
const [error, setError] = useState<
|
|
94
|
+
const [error, setError] = useState<unknown>(null);
|
|
95
95
|
const requestIdRef = useRef(0);
|
|
96
96
|
|
|
97
97
|
const fetchServerAggregations = useCallback(async () => {
|
|
@@ -22,7 +22,7 @@ interface UseGridDataSourceParams<R extends GridRowModel> {
|
|
|
22
22
|
setRows: (rows: R[] | ((prev: R[]) => R[]), preserveRowCount?: boolean) => void;
|
|
23
23
|
setRowCount: (count: number) => void;
|
|
24
24
|
setDataSourceLoading: (loading: boolean) => void;
|
|
25
|
-
setDataSourceError: (error:
|
|
25
|
+
setDataSourceError: (error: unknown) => void;
|
|
26
26
|
onAggregationResults?: (results: GridAggregationResult) => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from 'react';
|
|
1
|
+
import React, { useState, useCallback, useRef } from 'react';
|
|
2
2
|
import type { GridRowId, GridRowModel } from '../../types';
|
|
3
3
|
|
|
4
4
|
export interface GridEditingState {
|
|
5
|
-
|
|
6
5
|
editingCell: {
|
|
7
6
|
id: GridRowId;
|
|
8
7
|
field: string;
|
|
9
|
-
value:
|
|
10
|
-
originalValue:
|
|
8
|
+
value: unknown;
|
|
9
|
+
originalValue: unknown;
|
|
11
10
|
} | null;
|
|
12
11
|
}
|
|
13
12
|
|
|
@@ -15,41 +14,43 @@ export interface UseGridEditingParams<R extends GridRowModel> {
|
|
|
15
14
|
rows: R[];
|
|
16
15
|
getRowId: (row: R) => GridRowId;
|
|
17
16
|
processRowUpdate?: (newRow: R, oldRow: R) => R | Promise<R>;
|
|
18
|
-
onProcessRowUpdateError?: (error:
|
|
19
|
-
onRowChange?: (newRow: R) => void;
|
|
17
|
+
onProcessRowUpdateError?: (error: unknown) => void;
|
|
18
|
+
onRowChange?: (newRow: R) => void;
|
|
20
19
|
}
|
|
21
20
|
|
|
21
|
+
type EditingCellState = GridEditingState['editingCell'];
|
|
22
|
+
|
|
22
23
|
export function useGridEditing<R extends GridRowModel>(params: UseGridEditingParams<R>) {
|
|
23
|
-
const {
|
|
24
|
-
rows,
|
|
25
|
-
getRowId,
|
|
26
|
-
processRowUpdate,
|
|
24
|
+
const {
|
|
25
|
+
rows,
|
|
26
|
+
getRowId,
|
|
27
|
+
processRowUpdate,
|
|
27
28
|
onProcessRowUpdateError,
|
|
28
|
-
onRowChange
|
|
29
|
+
onRowChange
|
|
29
30
|
} = params;
|
|
30
31
|
|
|
31
|
-
const [editingCell, setEditingCellState] = useState<
|
|
32
|
-
const editingCellRef = useRef<
|
|
32
|
+
const [editingCell, setEditingCellState] = useState<EditingCellState>(null);
|
|
33
|
+
const editingCellRef = useRef<EditingCellState>(null);
|
|
33
34
|
|
|
34
|
-
const setEditingCell = useCallback((val:
|
|
35
|
+
const setEditingCell = useCallback((val: React.SetStateAction<EditingCellState>) => {
|
|
35
36
|
setEditingCellState(val);
|
|
36
37
|
editingCellRef.current = typeof val === 'function' ? val(editingCellRef.current) : val;
|
|
37
38
|
}, []);
|
|
38
39
|
|
|
39
|
-
const startCellEdit = useCallback((
|
|
40
|
+
const startCellEdit = useCallback((editParams: { id: GridRowId; field: string; value: unknown }) => {
|
|
40
41
|
setEditingCell({
|
|
41
|
-
id:
|
|
42
|
-
field:
|
|
43
|
-
value:
|
|
44
|
-
originalValue:
|
|
42
|
+
id: editParams.id,
|
|
43
|
+
field: editParams.field,
|
|
44
|
+
value: editParams.value,
|
|
45
|
+
originalValue: editParams.value
|
|
45
46
|
});
|
|
46
|
-
}, []);
|
|
47
|
+
}, [setEditingCell]);
|
|
47
48
|
|
|
48
|
-
const stopCellEdit = useCallback(async (
|
|
49
|
+
const stopCellEdit = useCallback(async (stopParams?: { cancel?: boolean }) => {
|
|
49
50
|
const currentCell = editingCellRef.current;
|
|
50
51
|
if (!currentCell) return;
|
|
51
52
|
|
|
52
|
-
if (
|
|
53
|
+
if (stopParams?.cancel) {
|
|
53
54
|
setEditingCell(null);
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
@@ -63,8 +64,8 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
|
|
|
63
64
|
|
|
64
65
|
const existingRow = rows.find(r => getRowId(r) === id);
|
|
65
66
|
if (!existingRow) {
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
setEditingCell(null);
|
|
68
|
+
return;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const newRow = { ...existingRow, [field]: value };
|
|
@@ -78,18 +79,16 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
|
|
|
78
79
|
}
|
|
79
80
|
setEditingCell(null);
|
|
80
81
|
} catch (error) {
|
|
81
|
-
|
|
82
|
-
onProcessRowUpdateError(error);
|
|
83
|
-
}
|
|
82
|
+
onProcessRowUpdateError?.(error);
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
}, [
|
|
85
|
+
}, [setEditingCell, rows, getRowId, processRowUpdate, onRowChange, onProcessRowUpdateError]);
|
|
87
86
|
|
|
88
|
-
const setEditCellValue = useCallback((
|
|
89
|
-
if (editingCell && editingCell.id ===
|
|
90
|
-
setEditingCell(
|
|
87
|
+
const setEditCellValue = useCallback((editParams: { id: GridRowId; field: string; value: unknown }) => {
|
|
88
|
+
if (editingCell && editingCell.id === editParams.id && editingCell.field === editParams.field) {
|
|
89
|
+
setEditingCell(prev => prev ? { ...prev, value: editParams.value } : null);
|
|
91
90
|
}
|
|
92
|
-
}, [editingCell]);
|
|
91
|
+
}, [editingCell, setEditingCell]);
|
|
93
92
|
|
|
94
93
|
return {
|
|
95
94
|
editingCell,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for usePivot — including regression tests for grand total correctness.
|
|
3
|
+
*
|
|
4
|
+
* Bug (fixed): avg/min/max grand total was computed by re-aggregating
|
|
5
|
+
* per-row pivot values instead of re-computing from the raw source rows.
|
|
6
|
+
* This produced wrong results whenever groups had unequal sizes.
|
|
7
|
+
*
|
|
8
|
+
* Example of the failure:
|
|
9
|
+
* Group A: avg salary = (30k + 50k) / 2 = 40k
|
|
10
|
+
* Group B: avg salary = (90k) / 1 = 90k
|
|
11
|
+
* Summing those per-row averages → 130k / 2 = 65k ← WRONG
|
|
12
|
+
* Correct grand avg → (30k + 50k + 90k) / 3 = 56.67k
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { renderHook } from '@testing-library/react';
|
|
17
|
+
import { usePivot } from './usePivot';
|
|
18
|
+
import type { GridColDef } from '../../types';
|
|
19
|
+
import type { GridPivotModel } from '../../types';
|
|
20
|
+
|
|
21
|
+
const COLS: GridColDef[] = [
|
|
22
|
+
{ field: 'dept', headerName: 'Department', width: 120 },
|
|
23
|
+
{ field: 'region', headerName: 'Region', width: 120 },
|
|
24
|
+
{ field: 'salary', headerName: 'Salary', width: 120, type: 'number' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Intentionally uneven groups so naive "average of averages" gives wrong results.
|
|
28
|
+
//
|
|
29
|
+
// dept=Eng → salaries [30000, 50000] → avg=40000, min=30000, max=50000
|
|
30
|
+
// dept=HR → salaries [90000] → avg=90000, min=90000, max=90000
|
|
31
|
+
//
|
|
32
|
+
// Grand total correct:
|
|
33
|
+
// avg = (30000+50000+90000)/3 ≈ 56666.67
|
|
34
|
+
// min = 30000
|
|
35
|
+
// max = 90000
|
|
36
|
+
const ROWS = [
|
|
37
|
+
{ id: 1, dept: 'Eng', region: 'US', salary: 30000 },
|
|
38
|
+
{ id: 2, dept: 'Eng', region: 'US', salary: 50000 },
|
|
39
|
+
{ id: 3, dept: 'HR', region: 'US', salary: 90000 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const MODEL_NO_COL_FIELDS: GridPivotModel = {
|
|
43
|
+
rowFields: ['dept'],
|
|
44
|
+
columnFields: [],
|
|
45
|
+
valueFields: [
|
|
46
|
+
{ field: 'salary', aggFn: 'sum' },
|
|
47
|
+
{ field: 'salary', aggFn: 'avg' },
|
|
48
|
+
{ field: 'salary', aggFn: 'count' },
|
|
49
|
+
{ field: 'salary', aggFn: 'min' },
|
|
50
|
+
{ field: 'salary', aggFn: 'max' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function grandTotalRow(pivotRows: ReturnType<typeof usePivot>['pivotRows']) {
|
|
55
|
+
return pivotRows.find(r => r.id === '__pivot_grand_total__')!;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// Basic functionality
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('usePivot — basic', () => {
|
|
63
|
+
it('returns isValid=false when disabled', () => {
|
|
64
|
+
const { result } = renderHook(() =>
|
|
65
|
+
usePivot(ROWS, COLS, MODEL_NO_COL_FIELDS, false)
|
|
66
|
+
);
|
|
67
|
+
expect(result.current.isValid).toBe(false);
|
|
68
|
+
expect(result.current.pivotRows).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns isValid=false when valueFields is empty', () => {
|
|
72
|
+
const { result } = renderHook(() =>
|
|
73
|
+
usePivot(ROWS, COLS, { ...MODEL_NO_COL_FIELDS, valueFields: [] }, true)
|
|
74
|
+
);
|
|
75
|
+
expect(result.current.isValid).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('produces one pivot row per unique rowField value plus grand total', () => {
|
|
79
|
+
const { result } = renderHook(() =>
|
|
80
|
+
usePivot(ROWS, COLS, MODEL_NO_COL_FIELDS, true)
|
|
81
|
+
);
|
|
82
|
+
// 2 groups (Eng, HR) + 1 grand total
|
|
83
|
+
expect(result.current.pivotRows).toHaveLength(3);
|
|
84
|
+
expect(result.current.isValid).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('computes per-group sum correctly', () => {
|
|
88
|
+
const model: GridPivotModel = {
|
|
89
|
+
...MODEL_NO_COL_FIELDS,
|
|
90
|
+
valueFields: [{ field: 'salary', aggFn: 'sum' }],
|
|
91
|
+
};
|
|
92
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
93
|
+
const rows = result.current.pivotRows;
|
|
94
|
+
const eng = rows.find(r => r.dept === 'Eng')!;
|
|
95
|
+
const hr = rows.find(r => r.dept === 'HR')!;
|
|
96
|
+
|
|
97
|
+
expect(eng['salarysum']).toBe(80000);
|
|
98
|
+
expect(hr['salarysum']).toBe(90000);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Regression: grand total avg/min/max
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe('usePivot — grand total regression', () => {
|
|
107
|
+
it('grand total sum = sum of all raw values', () => {
|
|
108
|
+
const model: GridPivotModel = {
|
|
109
|
+
...MODEL_NO_COL_FIELDS,
|
|
110
|
+
valueFields: [{ field: 'salary', aggFn: 'sum' }],
|
|
111
|
+
};
|
|
112
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
113
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
114
|
+
expect(gt['salarysum']).toBe(170000);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('grand total avg = raw mean, NOT average of per-group averages', () => {
|
|
118
|
+
// The naive bug: (40000 + 90000) / 2 = 65000
|
|
119
|
+
// The correct value: (30000 + 50000 + 90000) / 3 ≈ 56666.67
|
|
120
|
+
const model: GridPivotModel = {
|
|
121
|
+
...MODEL_NO_COL_FIELDS,
|
|
122
|
+
valueFields: [{ field: 'salary', aggFn: 'avg' }],
|
|
123
|
+
};
|
|
124
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
125
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
126
|
+
|
|
127
|
+
const correct = (30000 + 50000 + 90000) / 3;
|
|
128
|
+
const bugged = (40000 + 90000) / 2;
|
|
129
|
+
|
|
130
|
+
expect(gt['salaryavg']).toBeCloseTo(correct, 2);
|
|
131
|
+
expect(gt['salaryavg']).not.toBeCloseTo(bugged, 2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('grand total min = minimum across ALL raw rows, not minimum of per-group minimums', () => {
|
|
135
|
+
// With equal-size groups the two approaches coincidentally match.
|
|
136
|
+
// This dataset has unequal groups, so we must use raw data.
|
|
137
|
+
const model: GridPivotModel = {
|
|
138
|
+
...MODEL_NO_COL_FIELDS,
|
|
139
|
+
valueFields: [{ field: 'salary', aggFn: 'min' }],
|
|
140
|
+
};
|
|
141
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
142
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
143
|
+
expect(gt['salarymin']).toBe(30000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('grand total max = maximum across ALL raw rows', () => {
|
|
147
|
+
const model: GridPivotModel = {
|
|
148
|
+
...MODEL_NO_COL_FIELDS,
|
|
149
|
+
valueFields: [{ field: 'salary', aggFn: 'max' }],
|
|
150
|
+
};
|
|
151
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
152
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
153
|
+
expect(gt['salarymax']).toBe(90000);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('grand total count = total number of non-null raw values', () => {
|
|
157
|
+
const model: GridPivotModel = {
|
|
158
|
+
...MODEL_NO_COL_FIELDS,
|
|
159
|
+
valueFields: [{ field: 'salary', aggFn: 'count' }],
|
|
160
|
+
};
|
|
161
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
162
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
163
|
+
// sum of per-group counts = 2 + 1 = 3, which also equals total count here.
|
|
164
|
+
expect(gt['salarycount']).toBe(3);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('grand total avg ignores null salary values', () => {
|
|
168
|
+
const rowsWithNull = [
|
|
169
|
+
...ROWS,
|
|
170
|
+
{ id: 4, dept: 'Eng', region: 'US', salary: null as unknown as number },
|
|
171
|
+
];
|
|
172
|
+
const model: GridPivotModel = {
|
|
173
|
+
...MODEL_NO_COL_FIELDS,
|
|
174
|
+
valueFields: [{ field: 'salary', aggFn: 'avg' }],
|
|
175
|
+
};
|
|
176
|
+
const { result } = renderHook(() => usePivot(rowsWithNull, COLS, model, true));
|
|
177
|
+
const gt = grandTotalRow(result.current.pivotRows);
|
|
178
|
+
|
|
179
|
+
// null must be excluded: (30000+50000+90000)/3 not /4
|
|
180
|
+
const correct = (30000 + 50000 + 90000) / 3;
|
|
181
|
+
expect(gt['salaryavg']).toBeCloseTo(correct, 2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Pivot with column fields (colKeys drive dynamic column generation)
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
describe('usePivot — with columnFields', () => {
|
|
190
|
+
it('produces one pivot column per unique columnField value × valueField', () => {
|
|
191
|
+
const model: GridPivotModel = {
|
|
192
|
+
rowFields: ['dept'],
|
|
193
|
+
columnFields: ['region'],
|
|
194
|
+
valueFields: [{ field: 'salary', aggFn: 'sum' }],
|
|
195
|
+
};
|
|
196
|
+
const { result } = renderHook(() => usePivot(ROWS, COLS, model, true));
|
|
197
|
+
// colKeys = ['US'] → 1 col per valueField = 1 column (+ dept label column)
|
|
198
|
+
expect(result.current.colKeys).toEqual(['US']);
|
|
199
|
+
// Total pivot columns = 1 row-field col + 1 value col
|
|
200
|
+
expect(result.current.pivotColumns).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
Binary file
|