@opencorestack/opengridx 0.1.9 → 1.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 (57) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/index.d.ts +60 -41
  3. package/dist/opengridx.es.js +5045 -4549
  4. package/dist/opengridx.es.js.map +1 -1
  5. package/dist/opengridx.umd.js +11 -11
  6. package/dist/opengridx.umd.js.map +1 -1
  7. package/docs/README.md +14 -0
  8. package/docs/components/aggregation-footer.md +48 -0
  9. package/docs/components/datagrid.md +3 -0
  10. package/docs/components/empty-state.md +50 -0
  11. package/docs/components/error-overlay.md +40 -0
  12. package/docs/roadmap.md +20 -1
  13. package/lib/components/Cell/Cell.tsx +9 -10
  14. package/lib/components/Cell/GridEditInputCell.tsx +4 -4
  15. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  16. package/lib/components/DataGrid/DataGrid.regression.test.tsx +132 -0
  17. package/lib/components/DataGrid/DataGrid.tsx +336 -1018
  18. package/lib/components/DataGrid/GridAggregationFooter.tsx +68 -0
  19. package/lib/components/DataGrid/GridEmptyState.tsx +16 -0
  20. package/lib/components/DataGrid/GridErrorOverlay.tsx +33 -0
  21. package/lib/components/DataGrid/GridListView.tsx +116 -0
  22. package/lib/components/DataGrid/GridPinnedRows.tsx +98 -0
  23. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +124 -0
  24. package/lib/components/DataGrid/GridVirtualRows.tsx +202 -0
  25. package/lib/components/FilterPanel/FilterPanel.tsx +18 -10
  26. package/lib/components/Header/ColumnMenu.tsx +2 -2
  27. package/lib/components/Header/Header.tsx +4 -4
  28. package/lib/components/ListView/ListViewRow.tsx +2 -2
  29. package/lib/components/Row/Row.tsx +10 -10
  30. package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
  31. package/lib/components/Toolbar/GridToolbar.tsx +6 -0
  32. package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
  33. package/lib/hooks/core/useDataGrid.ts +14 -34
  34. package/lib/hooks/core/useGridColumns.tsx +228 -0
  35. package/lib/hooks/core/useGridControlledState.ts +192 -0
  36. package/lib/hooks/core/useGridRowPipeline.ts +109 -0
  37. package/lib/hooks/core/useGridScrollSync.ts +52 -0
  38. package/lib/hooks/core/useGridStateSnapshot.ts +63 -0
  39. package/lib/hooks/core/useGridVirtualization.ts +186 -0
  40. package/lib/hooks/core/useGridVisibleRows.ts +66 -0
  41. package/lib/hooks/core/useLayout.ts +0 -6
  42. package/lib/hooks/features/useAggregation.ts +7 -7
  43. package/lib/hooks/features/useGridDataSource.ts +1 -1
  44. package/lib/hooks/features/useGridEditing.ts +31 -32
  45. package/lib/hooks/features/usePivot.test.ts +202 -0
  46. package/lib/hooks/features/usePivot.ts +0 -0
  47. package/lib/hooks/useColumnReorder.ts +1 -1
  48. package/lib/hooks/useRowGrouping.ts +14 -14
  49. package/lib/hooks/useTreeData.ts +1 -1
  50. package/lib/state/types.ts +1 -1
  51. package/lib/state/useGridStateStorage.ts +1 -1
  52. package/lib/types/index.ts +48 -32
  53. package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
  54. package/lib/utils/export/index.ts +22 -17
  55. package/lib/utils/filtering/index.ts +20 -20
  56. package/lib/utils/sorting/index.ts +1 -1
  57. 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: any[]) => any> = {
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: any, fnName: string): string {
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: any;
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 any)[field]);
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<any>(null);
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: any) => void;
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: any;
10
- originalValue: any;
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: any) => void;
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<GridEditingState['editingCell']>(null);
32
- const editingCellRef = useRef<GridEditingState['editingCell']>(null);
32
+ const [editingCell, setEditingCellState] = useState<EditingCellState>(null);
33
+ const editingCellRef = useRef<EditingCellState>(null);
33
34
 
34
- const setEditingCell = useCallback((val: any) => {
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((params: { id: GridRowId, field: string, value: any }) => {
40
+ const startCellEdit = useCallback((editParams: { id: GridRowId; field: string; value: unknown }) => {
40
41
  setEditingCell({
41
- id: params.id,
42
- field: params.field,
43
- value: params.value,
44
- originalValue: params.value
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 (params?: { cancel?: boolean }) => {
49
+ const stopCellEdit = useCallback(async (stopParams?: { cancel?: boolean }) => {
49
50
  const currentCell = editingCellRef.current;
50
51
  if (!currentCell) return;
51
52
 
52
- if (params?.cancel) {
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
- setEditingCell(null);
67
- return;
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
- if (onProcessRowUpdateError) {
82
- onProcessRowUpdateError(error);
83
- }
82
+ onProcessRowUpdateError?.(error);
84
83
  }
85
84
 
86
- }, [editingCell, rows, getRowId, processRowUpdate, onRowChange, onProcessRowUpdateError]);
85
+ }, [setEditingCell, rows, getRowId, processRowUpdate, onRowChange, onProcessRowUpdateError]);
87
86
 
88
- const setEditCellValue = useCallback((params: { id: GridRowId, field: string, value: any }) => {
89
- if (editingCell && editingCell.id === params.id && editingCell.field === params.field) {
90
- setEditingCell((prev: any) => prev ? { ...prev, value: params.value } : null);
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
@@ -84,7 +84,7 @@ export function useColumnReorder<R extends GridRowModel = GridRowModel>(
84
84
 
85
85
  if (onColumnOrderChange) {
86
86
  onColumnOrderChange({
87
- column: column as any,
87
+ column: column as unknown as GridColDef,
88
88
  oldIndex,
89
89
  targetIndex,
90
90
  });