@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.
- package/CHANGELOG.md +59 -0
- package/dist/index.d.ts +60 -41
- package/dist/opengridx.es.js +5271 -4660
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +12 -12
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/README.md +14 -0
- package/docs/components/aggregation-footer.md +48 -0
- package/docs/components/datagrid.md +3 -0
- package/docs/components/empty-state.md +50 -0
- package/docs/components/error-overlay.md +40 -0
- package/docs/roadmap.md +20 -1
- package/lib/components/Cell/Cell.tsx +61 -64
- 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.smoke.test.tsx +58 -0
- package/lib/components/DataGrid/DataGrid.tsx +396 -1654
- 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 +46 -45
- package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
- package/lib/components/Toolbar/GlobalSearch.tsx +1 -1
- package/lib/components/Toolbar/GridToolbar.tsx +26 -5
- package/lib/components/Toolbar/PivotPanel.tsx +7 -2
- package/lib/components/ui/DragHandleIcon.tsx +1 -1
- package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
- package/lib/hooks/core/useDataGrid.ts +15 -35
- package/lib/hooks/core/useGridColumns.tsx +228 -0
- package/lib/hooks/core/useGridControlledState.ts +192 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -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 +348 -0
- package/lib/hooks/features/useAggregation.test.ts +135 -0
- package/lib/hooks/features/useAggregation.ts +7 -7
- package/lib/hooks/features/useGridClipboard.ts +1 -1
- package/lib/hooks/features/useGridDataSource.ts +2 -2
- 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 +2 -2
- package/lib/types/index.ts +48 -32
- package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
- package/lib/utils/export/index.ts +23 -18
- package/lib/utils/filtering/filtering.test.ts +178 -0
- package/lib/utils/filtering/index.ts +20 -20
- package/lib/utils/sorting/index.ts +1 -1
- package/lib/utils/sorting/sorting.test.ts +88 -0
- package/package.json +12 -5
- 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:
|
|
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
|
|
|
@@ -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:
|
|
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
|
|
@@ -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:
|
|
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<
|
|
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,
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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 =
|
|
247
|
-
const valB =
|
|
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
|
|
package/lib/hooks/useTreeData.ts
CHANGED
|
@@ -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);
|
package/lib/state/types.ts
CHANGED
|
@@ -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);
|