@opencorestack/opengridx 0.1.8 → 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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/index.d.ts +60 -41
  3. package/dist/opengridx.es.js +5271 -4660
  4. package/dist/opengridx.es.js.map +1 -1
  5. package/dist/opengridx.umd.js +12 -12
  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 +61 -64
  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.smoke.test.tsx +58 -0
  18. package/lib/components/DataGrid/DataGrid.tsx +396 -1654
  19. package/lib/components/DataGrid/GridAggregationFooter.tsx +68 -0
  20. package/lib/components/DataGrid/GridEmptyState.tsx +16 -0
  21. package/lib/components/DataGrid/GridErrorOverlay.tsx +33 -0
  22. package/lib/components/DataGrid/GridListView.tsx +116 -0
  23. package/lib/components/DataGrid/GridPinnedRows.tsx +98 -0
  24. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +124 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +202 -0
  26. package/lib/components/FilterPanel/FilterPanel.tsx +18 -10
  27. package/lib/components/Header/ColumnMenu.tsx +2 -2
  28. package/lib/components/Header/Header.tsx +4 -4
  29. package/lib/components/ListView/ListViewRow.tsx +2 -2
  30. package/lib/components/Row/Row.tsx +46 -45
  31. package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
  32. package/lib/components/Toolbar/GlobalSearch.tsx +1 -1
  33. package/lib/components/Toolbar/GridToolbar.tsx +26 -5
  34. package/lib/components/Toolbar/PivotPanel.tsx +7 -2
  35. package/lib/components/ui/DragHandleIcon.tsx +1 -1
  36. package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
  37. package/lib/hooks/core/useDataGrid.ts +15 -35
  38. package/lib/hooks/core/useGridColumns.tsx +228 -0
  39. package/lib/hooks/core/useGridControlledState.ts +192 -0
  40. package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -0
  41. package/lib/hooks/core/useGridRowPipeline.ts +109 -0
  42. package/lib/hooks/core/useGridScrollSync.ts +52 -0
  43. package/lib/hooks/core/useGridStateSnapshot.ts +63 -0
  44. package/lib/hooks/core/useGridVirtualization.ts +186 -0
  45. package/lib/hooks/core/useGridVisibleRows.ts +66 -0
  46. package/lib/hooks/core/useLayout.ts +348 -0
  47. package/lib/hooks/features/useAggregation.test.ts +135 -0
  48. package/lib/hooks/features/useAggregation.ts +7 -7
  49. package/lib/hooks/features/useGridClipboard.ts +1 -1
  50. package/lib/hooks/features/useGridDataSource.ts +2 -2
  51. package/lib/hooks/features/useGridEditing.ts +31 -32
  52. package/lib/hooks/features/usePivot.test.ts +202 -0
  53. package/lib/hooks/features/usePivot.ts +0 -0
  54. package/lib/hooks/useColumnReorder.ts +1 -1
  55. package/lib/hooks/useRowGrouping.ts +14 -14
  56. package/lib/hooks/useTreeData.ts +1 -1
  57. package/lib/state/types.ts +1 -1
  58. package/lib/state/useGridStateStorage.ts +2 -2
  59. package/lib/types/index.ts +48 -32
  60. package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
  61. package/lib/utils/export/index.ts +23 -18
  62. package/lib/utils/filtering/filtering.test.ts +178 -0
  63. package/lib/utils/filtering/index.ts +20 -20
  64. package/lib/utils/sorting/index.ts +1 -1
  65. package/lib/utils/sorting/sorting.test.ts +88 -0
  66. package/package.json +12 -5
  67. package/lib/utils/virtualization/index.ts +0 -106
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useAggregation, formatAggregationValue } from './useAggregation';
4
+
5
+ const ROWS = [
6
+ { id: 1, salary: 50000, dept: 'Eng', active: true },
7
+ { id: 2, salary: 60000, dept: 'Eng', active: false },
8
+ { id: 3, salary: 70000, dept: 'HR', active: true },
9
+ { id: 4, salary: null, dept: 'HR', active: true },
10
+ { id: 5, salary: 80000, dept: 'Eng', active: null },
11
+ ];
12
+
13
+ describe('formatAggregationValue', () => {
14
+ it('returns em-dash for null', () => {
15
+ expect(formatAggregationValue(null, 'sum')).toBe('—');
16
+ });
17
+ it('formats avg with up to 2 decimal places', () => {
18
+ expect(formatAggregationValue(1234.5678, 'avg')).toBe('1,234.57');
19
+ });
20
+ it('formats non-avg numbers without decimals', () => {
21
+ expect(formatAggregationValue(1234567, 'sum')).toBe('1,234,567');
22
+ });
23
+ it('converts non-numeric to string', () => {
24
+ expect(formatAggregationValue('Engineering', 'unique')).toBe('Engineering');
25
+ });
26
+ });
27
+
28
+ describe('useAggregation — client-side', () => {
29
+ it('returns empty result when no aggregation model', () => {
30
+ const { result } = renderHook(() =>
31
+ useAggregation({ rows: ROWS, aggregationModel: {}, isServerSide: false })
32
+ );
33
+ expect(result.current.aggregationResult).toEqual({});
34
+ });
35
+
36
+ it('computes sum correctly, skipping nulls', () => {
37
+ const { result } = renderHook(() =>
38
+ useAggregation({
39
+ rows: ROWS,
40
+ aggregationModel: { salary: 'sum' },
41
+ isServerSide: false,
42
+ })
43
+ );
44
+ expect(result.current.aggregationResult.salary).toBe(260000);
45
+ });
46
+
47
+ it('computes avg correctly', () => {
48
+ const { result } = renderHook(() =>
49
+ useAggregation({
50
+ rows: ROWS,
51
+ aggregationModel: { salary: 'avg' },
52
+ isServerSide: false,
53
+ })
54
+ );
55
+ expect(result.current.aggregationResult.salary).toBeCloseTo(65000);
56
+ });
57
+
58
+ it('computes count of non-null values', () => {
59
+ const { result } = renderHook(() =>
60
+ useAggregation({
61
+ rows: ROWS,
62
+ aggregationModel: { salary: 'count' },
63
+ isServerSide: false,
64
+ })
65
+ );
66
+ expect(result.current.aggregationResult.salary).toBe(4);
67
+ });
68
+
69
+ it('computes min and max', () => {
70
+ const { result } = renderHook(() =>
71
+ useAggregation({
72
+ rows: ROWS,
73
+ aggregationModel: { salary: 'min' },
74
+ isServerSide: false,
75
+ })
76
+ );
77
+ expect(result.current.aggregationResult.salary).toBe(50000);
78
+
79
+ const { result: r2 } = renderHook(() =>
80
+ useAggregation({
81
+ rows: ROWS,
82
+ aggregationModel: { salary: 'max' },
83
+ isServerSide: false,
84
+ })
85
+ );
86
+ expect(r2.current.aggregationResult.salary).toBe(80000);
87
+ });
88
+
89
+ it('computes unique count', () => {
90
+ const { result } = renderHook(() =>
91
+ useAggregation({
92
+ rows: ROWS,
93
+ aggregationModel: { dept: 'unique' },
94
+ isServerSide: false,
95
+ })
96
+ );
97
+ expect(result.current.aggregationResult.dept).toBe(2);
98
+ });
99
+
100
+ it('handles unknown function gracefully — skips the field', () => {
101
+ const { result } = renderHook(() =>
102
+ useAggregation({
103
+ rows: ROWS,
104
+ aggregationModel: { salary: 'median' as 'sum' },
105
+ isServerSide: false,
106
+ })
107
+ );
108
+ expect(result.current.aggregationResult.salary).toBeUndefined();
109
+ });
110
+
111
+ it('returns {} and does not mutate state when isServerSide=true', () => {
112
+ const { result } = renderHook(() =>
113
+ useAggregation({
114
+ rows: ROWS,
115
+ aggregationModel: { salary: 'sum' },
116
+ isServerSide: true,
117
+ })
118
+ );
119
+ // No dataSource → serverResult stays {}
120
+ expect(result.current.aggregationResult).toEqual({});
121
+ });
122
+
123
+ it('returns serverAggregationResults when provided', () => {
124
+ const serverResults = { salary: 999999 };
125
+ const { result } = renderHook(() =>
126
+ useAggregation({
127
+ rows: ROWS,
128
+ aggregationModel: { salary: 'sum' },
129
+ isServerSide: true,
130
+ serverAggregationResults: serverResults,
131
+ })
132
+ );
133
+ expect(result.current.aggregationResult).toEqual(serverResults);
134
+ });
135
+ });
@@ -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 () => {
@@ -34,7 +34,7 @@ async function writeToClipboard(text: string): Promise<void> {
34
34
  let fallbackSuccess = false;
35
35
  try {
36
36
  fallbackSuccess = copyTextSynchronous(text);
37
- } catch (e) {
37
+ } catch {
38
38
  // Ignore fallback errors
39
39
  }
40
40
 
@@ -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
 
@@ -108,7 +108,7 @@ export function useGridDataSource<R extends GridRowModel>(params: UseGridDataSou
108
108
  if (response.rowCount !== undefined) {
109
109
  setRowCount(response.rowCount);
110
110
  } else if (paginationMode === 'infinite') {
111
-
111
+ // rowCount is not required for infinite scroll — the grid uses appendRows
112
112
  }
113
113
 
114
114
  prevParamsRef.current = {
@@ -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
  });
@@ -50,7 +50,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
50
50
  return { treeNodes, rootIds, groupingRows };
51
51
  }
52
52
 
53
- const getGroupId = (field: string, value: any, parentId: GridRowId | null) => {
53
+ const getGroupId = (field: string, value: unknown, parentId: GridRowId | null) => {
54
54
  return `auto-group-${field}-${value}-${parentId || 'root'}`;
55
55
  };
56
56
 
@@ -75,7 +75,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
75
75
  }
76
76
 
77
77
  const field = rowGroupingModel[depth];
78
- const groups = new Map<any, R[]>();
78
+ const groups = new Map<string, R[]>();
79
79
 
80
80
  // Group current rows by value of the current field
81
81
  currentRows.forEach(row => {
@@ -99,18 +99,18 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
99
99
  groupIds.push(groupId);
100
100
 
101
101
  // Calculate Aggregations for this group
102
- const aggregatedValues: Record<string, any> = {};
102
+ const aggregatedValues: Record<string, unknown> = {};
103
103
  if (aggregationModel) {
104
104
  Object.entries(aggregationModel).forEach(([aggField, aggType]) => {
105
105
  const values = groupRowsList.map(r => r[aggField]);
106
106
  if (aggType === 'sum') {
107
- aggregatedValues[aggField] = values.reduce((a, b) => (Number(a) || 0) + (Number(b) || 0), 0);
107
+ aggregatedValues[aggField] = values.reduce((a: number, b) => a + (Number(b) || 0), 0);
108
108
  } else if (aggType === 'min') {
109
109
  aggregatedValues[aggField] = Math.min(...values.map(v => Number(v) || 0));
110
110
  } else if (aggType === 'max') {
111
111
  aggregatedValues[aggField] = Math.max(...values.map(v => Number(v) || 0));
112
112
  } else if (aggType === 'avg') {
113
- const sum = values.reduce((a, b) => (Number(a) || 0) + (Number(b) || 0), 0);
113
+ const sum = values.reduce((a: number, b) => a + (Number(b) || 0), 0);
114
114
  aggregatedValues[aggField] = values.length ? sum / values.length : 0;
115
115
  } else if (aggType === 'count') {
116
116
  aggregatedValues[aggField] = values.length;
@@ -118,11 +118,11 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
118
118
  });
119
119
  }
120
120
 
121
- const groupRow: any = {
122
- [field]: rawValue,
123
- ...aggregatedValues,
124
- id: groupId
125
- };
121
+ const groupRow = {
122
+ [field]: rawValue,
123
+ ...aggregatedValues,
124
+ id: groupId
125
+ } as unknown as R;
126
126
 
127
127
  groupingRows.set(groupId, groupRow);
128
128
 
@@ -183,7 +183,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
183
183
  setExpandedGroupIds(new Set());
184
184
  }
185
185
 
186
- }, [rowGroupingModel, defaultGroupingExpansionDepth]);
186
+ }, [rowGroupingModel, defaultGroupingExpansionDepth, treeNodes]);
187
187
 
188
188
  const toggleExpansion = useCallback((id: GridRowId) => {
189
189
  setExpandedGroupIds(prev => {
@@ -243,8 +243,8 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
243
243
  if (!rowA || !rowB) return 0;
244
244
 
245
245
  for (const sortItem of sortModel) {
246
- const valA = (rowA as any)[sortItem.field];
247
- const valB = (rowB as any)[sortItem.field];
246
+ const valA = rowA[sortItem.field];
247
+ const valB = rowB[sortItem.field];
248
248
  const compareResult = compareValues(valA, valB, sortItem.sort);
249
249
  if (compareResult !== 0) return compareResult;
250
250
  }
@@ -287,7 +287,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
287
287
 
288
288
  return result;
289
289
 
290
- }, [rows, getRowId, treeNodes, rootIds, groupingRows, filterModel, sortModel, expandedGroupIds]);
290
+ }, [rows, getRowId, treeNodes, rootIds, groupingRows, filterModel, sortModel, expandedGroupIds, rowGroupingModel]);
291
291
 
292
292
  const getNode = useCallback((id: GridRowId) => treeNodes.get(id), [treeNodes]);
293
293
 
@@ -277,7 +277,7 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
277
277
  ...row,
278
278
  _treeDepth: node.depth,
279
279
  _isExpanded: expandedGroupIds.has(id),
280
- _hasChildren: (node.children && node.children.length > 0) || (node.serverChildrenCount && node.serverChildrenCount > 0),
280
+ _hasChildren: Boolean((node.children && node.children.length > 0) || (node.serverChildrenCount && node.serverChildrenCount > 0)),
281
281
  _descendantCount: node.children ? node.children.length : 0
282
282
  };
283
283
  result.push(enhancedRow);
@@ -22,7 +22,7 @@ export interface GridPaginationState {
22
22
 
23
23
  export interface GridDataSourceState {
24
24
  loading: boolean;
25
- error?: any;
25
+ error?: unknown;
26
26
  }
27
27
 
28
28
  export interface GridColumnsState {
@@ -47,7 +47,7 @@ function writeToStorage(
47
47
  : state;
48
48
  storage!.setItem(key, JSON.stringify(toWrite));
49
49
  } catch {
50
-
50
+ // swallow localStorage write errors (private browsing, quota exceeded)
51
51
  }
52
52
  }
53
53
 
@@ -70,7 +70,7 @@ export function useGridStateStorage(
70
70
  if (!storage) return undefined;
71
71
  return readFromStorage(key, storage);
72
72
 
73
- }, [key]);
73
+ }, [key, storage]);
74
74
 
75
75
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
76
76
  const latestStateRef = useRef<GridState | null>(null);