@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
GridFilterModel,
|
|
4
4
|
GridFilterItem,
|
|
@@ -30,34 +30,42 @@ const FilterRow: React.FC<{
|
|
|
30
30
|
const currentOperator = item?.operator ?? operators[0];
|
|
31
31
|
|
|
32
32
|
// Local input state to prevent full filter-panel re-render on every keystroke
|
|
33
|
-
const [localValue, setLocalValue] = useState(item?.value
|
|
33
|
+
const [localValue, setLocalValue] = useState<string>(item?.value != null ? String(item.value) : '');
|
|
34
34
|
const onChangeRef = useRef(onChange);
|
|
35
|
-
|
|
35
|
+
const itemRef = useRef(item);
|
|
36
|
+
const colFieldRef = useRef(col.field);
|
|
37
|
+
const operatorRef = useRef(currentOperator);
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
onChangeRef.current = onChange;
|
|
40
|
+
itemRef.current = item;
|
|
41
|
+
colFieldRef.current = col.field;
|
|
42
|
+
operatorRef.current = currentOperator;
|
|
43
|
+
});
|
|
36
44
|
|
|
37
45
|
// Sync when the external item changes (e.g. operator change, clear)
|
|
38
46
|
const prevItemValue = useRef(item?.value);
|
|
39
47
|
useEffect(() => {
|
|
40
48
|
if (item?.value !== prevItemValue.current) {
|
|
41
49
|
prevItemValue.current = item?.value;
|
|
42
|
-
setLocalValue(item?.value
|
|
50
|
+
setLocalValue(item?.value != null ? String(item.value) : '');
|
|
43
51
|
}
|
|
44
52
|
}, [item?.value]);
|
|
45
53
|
|
|
46
54
|
// Debounce propagating the value upward (300ms)
|
|
47
55
|
useEffect(() => {
|
|
48
56
|
const handler = setTimeout(() => {
|
|
49
|
-
const
|
|
57
|
+
const currentItem = itemRef.current;
|
|
58
|
+
const currentValue = currentItem?.value ?? '';
|
|
50
59
|
if (localValue !== currentValue) {
|
|
51
60
|
onChangeRef.current({
|
|
52
|
-
id:
|
|
53
|
-
field:
|
|
54
|
-
operator:
|
|
61
|
+
id: currentItem?.id ?? `${colFieldRef.current}-${Date.now()}`,
|
|
62
|
+
field: colFieldRef.current,
|
|
63
|
+
operator: operatorRef.current as GridFilterOperator,
|
|
55
64
|
value: localValue,
|
|
56
65
|
});
|
|
57
66
|
}
|
|
58
67
|
}, 300);
|
|
59
68
|
return () => clearTimeout(handler);
|
|
60
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
69
|
}, [localValue]);
|
|
62
70
|
|
|
63
71
|
const handleOperatorChange = (op: string) => {
|
|
@@ -158,7 +166,7 @@ const FilterRow: React.FC<{
|
|
|
158
166
|
// ── Root panel ────────────────────────────────────────────────────────────────
|
|
159
167
|
|
|
160
168
|
export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns, onFilterModelChange }) => {
|
|
161
|
-
const items
|
|
169
|
+
const items = useMemo<GridFilterItem[]>(() => (filterModel.items ?? []) as GridFilterItem[], [filterModel.items]);
|
|
162
170
|
const logicOperator = filterModel.logicOperator ?? 'and';
|
|
163
171
|
|
|
164
172
|
const handleItemChange = useCallback((field: string, newItem: GridFilterItem | null) => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { useRef, useEffect, useState } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
|
-
import type { GridColDef, GridSortDirection, GridColumnPinning } from '../../types';
|
|
3
|
+
import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning } from '../../types';
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
interface ColumnMenuProps {
|
|
7
|
-
colDef: GridColDef<
|
|
7
|
+
colDef: GridColDef<GridRowModel>;
|
|
8
8
|
sortModel?: Array<{ field: string; sort: 'asc' | 'desc' }>;
|
|
9
9
|
onSort?: (field: string, direction: GridSortDirection) => void;
|
|
10
10
|
onHide?: (field: string) => void;
|
|
@@ -236,7 +236,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
236
236
|
// All non-spacer fields in order (for group-span math) with their effective physical width
|
|
237
237
|
const allCols = allColumns ?? columns;
|
|
238
238
|
const allLeafFields = allCols
|
|
239
|
-
.filter(c => !
|
|
239
|
+
.filter(c => !c.isSpacer)
|
|
240
240
|
.map(c => ({
|
|
241
241
|
field: c.field,
|
|
242
242
|
width: columnWidths[c.field] ?? c.width ?? 100
|
|
@@ -354,7 +354,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
354
354
|
)}
|
|
355
355
|
|
|
356
356
|
{columns.map((colDef, index) => {
|
|
357
|
-
if (
|
|
357
|
+
if (colDef.isSpacer) {
|
|
358
358
|
return (
|
|
359
359
|
<div
|
|
360
360
|
key={colDef.field}
|
|
@@ -409,7 +409,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
const headerContent = colDef.renderHeader
|
|
412
|
-
? colDef.renderHeader({ field: colDef.field, colDef: colDef as
|
|
412
|
+
? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex: index })
|
|
413
413
|
: colDef.headerName || colDef.field;
|
|
414
414
|
|
|
415
415
|
const handleHeaderClick = (e: React.MouseEvent) => {
|
|
@@ -499,7 +499,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
499
499
|
|
|
500
500
|
{menuOpenParams && (
|
|
501
501
|
<ColumnMenu
|
|
502
|
-
colDef={menuOpenParams.colDef}
|
|
502
|
+
colDef={menuOpenParams.colDef as unknown as GridColDef<GridRowModel>}
|
|
503
503
|
sortModel={sortModel}
|
|
504
504
|
onSort={onSort}
|
|
505
505
|
onHide={onHideColumn}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { GridRowModel, GridRowId, GridListViewColDef, GridRenderCellParams } from '../../types';
|
|
2
|
+
import type { GridColDef, GridRowModel, GridRowId, GridListViewColDef, GridRenderCellParams } from '../../types';
|
|
3
3
|
|
|
4
4
|
export interface ListViewRowProps<R extends GridRowModel = GridRowModel> {
|
|
5
5
|
row: R;
|
|
@@ -26,7 +26,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
|
|
|
26
26
|
row,
|
|
27
27
|
value: undefined,
|
|
28
28
|
field: listViewColumn.field,
|
|
29
|
-
colDef: { field: listViewColumn.field } as
|
|
29
|
+
colDef: { field: listViewColumn.field } as unknown as GridColDef<R>,
|
|
30
30
|
rowIndex,
|
|
31
31
|
colIndex: 0,
|
|
32
32
|
};
|
|
@@ -40,10 +40,10 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
|
|
|
40
40
|
isDragging?: boolean;
|
|
41
41
|
isDragOver?: boolean;
|
|
42
42
|
|
|
43
|
-
editingCell?: { id: GridRowId; field: string; value:
|
|
44
|
-
onEditStart?: (params: { id: GridRowId, field: string, value:
|
|
43
|
+
editingCell?: { id: GridRowId; field: string; value: unknown; } | null;
|
|
44
|
+
onEditStart?: (params: { id: GridRowId, field: string, value: unknown }) => void;
|
|
45
45
|
onEditStop?: (params?: { cancel?: boolean }) => void;
|
|
46
|
-
onEditCellValueChange?: (params: { id: GridRowId, field: string, value:
|
|
46
|
+
onEditCellValueChange?: (params: { id: GridRowId, field: string, value: unknown }) => void;
|
|
47
47
|
|
|
48
48
|
focusedCellField?: string | null;
|
|
49
49
|
isFocusVisible?: boolean;
|
|
@@ -97,8 +97,47 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
97
97
|
const expandCellRef = React.useRef<HTMLDivElement>(null);
|
|
98
98
|
const checkboxCellRef = React.useRef<HTMLDivElement>(null);
|
|
99
99
|
|
|
100
|
+
// All hooks must come before any early returns (Rules of Hooks)
|
|
101
|
+
React.useLayoutEffect(() => {
|
|
102
|
+
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
103
|
+
expandCellRef.current.focus({ preventScroll: true });
|
|
104
|
+
}
|
|
105
|
+
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
106
|
+
checkboxCellRef.current.focus({ preventScroll: true });
|
|
107
|
+
}
|
|
108
|
+
}, [focusedCellField]);
|
|
109
|
+
|
|
110
|
+
const pinnedPositions = React.useMemo(() => {
|
|
111
|
+
return calculatePinnedPositions(
|
|
112
|
+
columns,
|
|
113
|
+
columnWidths,
|
|
114
|
+
pinnedColumns,
|
|
115
|
+
checkboxSelection,
|
|
116
|
+
pinCheckboxColumn,
|
|
117
|
+
hasDetailPanel,
|
|
118
|
+
pinExpandColumn,
|
|
119
|
+
rowReordering
|
|
120
|
+
);
|
|
121
|
+
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
122
|
+
|
|
123
|
+
const isGroupRow = row._hasChildren === true;
|
|
124
|
+
|
|
125
|
+
const handleCellEditStart = React.useCallback((field: string, value: unknown) => {
|
|
126
|
+
if (!isGroupRow) {
|
|
127
|
+
onEditStart?.({ id: row.id, field, value });
|
|
128
|
+
}
|
|
129
|
+
}, [isGroupRow, row.id, onEditStart]);
|
|
130
|
+
|
|
131
|
+
const handleCellValueChange = React.useCallback((field: string, newValue: unknown) => {
|
|
132
|
+
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
133
|
+
}, [onEditCellValueChange, row.id]);
|
|
134
|
+
|
|
135
|
+
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
136
|
+
onEditStop?.({ cancel });
|
|
137
|
+
}, [onEditStop]);
|
|
138
|
+
|
|
100
139
|
// ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
|
|
101
|
-
if (
|
|
140
|
+
if (row._isSkeleton) {
|
|
102
141
|
// Render shimmer placeholders — one per visible column, matching row height
|
|
103
142
|
const cellCount = Math.max(columns.length, 5);
|
|
104
143
|
return (
|
|
@@ -117,15 +156,6 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
117
156
|
);
|
|
118
157
|
}
|
|
119
158
|
|
|
120
|
-
React.useLayoutEffect(() => {
|
|
121
|
-
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
122
|
-
expandCellRef.current.focus({ preventScroll: true });
|
|
123
|
-
}
|
|
124
|
-
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
125
|
-
checkboxCellRef.current.focus({ preventScroll: true });
|
|
126
|
-
}
|
|
127
|
-
}, [focusedCellField]);
|
|
128
|
-
|
|
129
159
|
const handleRowClick = (event: React.MouseEvent) => {
|
|
130
160
|
|
|
131
161
|
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
|
|
@@ -144,39 +174,10 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
144
174
|
onDetailPanelToggle?.(row.id);
|
|
145
175
|
};
|
|
146
176
|
|
|
147
|
-
const pinnedPositions = React.useMemo(() => {
|
|
148
|
-
return calculatePinnedPositions(
|
|
149
|
-
columns,
|
|
150
|
-
columnWidths,
|
|
151
|
-
pinnedColumns,
|
|
152
|
-
checkboxSelection,
|
|
153
|
-
pinCheckboxColumn,
|
|
154
|
-
hasDetailPanel,
|
|
155
|
-
pinExpandColumn,
|
|
156
|
-
rowReordering
|
|
157
|
-
);
|
|
158
|
-
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
159
|
-
|
|
160
177
|
const rowPinnedPosition = isRowPinned(row.id, pinnedRows);
|
|
161
178
|
const isRowPinnedTop = rowPinnedPosition === 'top';
|
|
162
179
|
const isRowPinnedBottom = rowPinnedPosition === 'bottom';
|
|
163
180
|
|
|
164
|
-
const isGroupRow = (row as any)._hasChildren === true;
|
|
165
|
-
|
|
166
|
-
const handleCellEditStart = React.useCallback((field: string, value: any) => {
|
|
167
|
-
if (!isGroupRow) {
|
|
168
|
-
onEditStart?.({ id: row.id, field, value });
|
|
169
|
-
}
|
|
170
|
-
}, [isGroupRow, row.id, onEditStart]);
|
|
171
|
-
|
|
172
|
-
const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
|
|
173
|
-
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
174
|
-
}, [onEditCellValueChange, row.id]);
|
|
175
|
-
|
|
176
|
-
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
177
|
-
onEditStop?.({ cancel });
|
|
178
|
-
}, [onEditStop]);
|
|
179
|
-
|
|
180
181
|
const classNames = [
|
|
181
182
|
'ogx__row',
|
|
182
183
|
isSelected && 'ogx__row--selected',
|
|
@@ -249,7 +250,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
249
250
|
row,
|
|
250
251
|
field: '__expand_col__',
|
|
251
252
|
value: isDetailPanelExpanded,
|
|
252
|
-
colDef: { field: '__expand_col__', width: 48 } as
|
|
253
|
+
colDef: { field: '__expand_col__', width: 48 } as unknown as GridColDef<R>,
|
|
253
254
|
rowIndex,
|
|
254
255
|
colIndex: -1
|
|
255
256
|
});
|
|
@@ -283,7 +284,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
283
284
|
row,
|
|
284
285
|
field: '__checkbox_col__',
|
|
285
286
|
value: isSelected,
|
|
286
|
-
colDef: { field: '__checkbox_col__', width: 48 } as
|
|
287
|
+
colDef: { field: '__checkbox_col__', width: 48 } as unknown as GridColDef<R>,
|
|
287
288
|
rowIndex,
|
|
288
289
|
colIndex: -1
|
|
289
290
|
});
|
|
@@ -304,7 +305,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
304
305
|
|
|
305
306
|
{ }
|
|
306
307
|
{columns.map((colDef, colIndex) => {
|
|
307
|
-
if (
|
|
308
|
+
if (colDef.isSpacer) {
|
|
308
309
|
return (
|
|
309
310
|
<div
|
|
310
311
|
key={colDef.field}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { GridColDef } from '../../types';
|
|
2
3
|
|
|
4
|
+
interface SkeletonColumn extends GridColDef {
|
|
5
|
+
left?: number;
|
|
6
|
+
right?: number;
|
|
7
|
+
pinned?: 'left' | 'right';
|
|
8
|
+
zIndex?: number;
|
|
9
|
+
}
|
|
3
10
|
|
|
4
11
|
interface SkeletonRowProps {
|
|
5
|
-
columns:
|
|
12
|
+
columns: SkeletonColumn[];
|
|
6
13
|
rowHeight: number;
|
|
7
14
|
checkboxSelection?: boolean;
|
|
8
15
|
hasDetailPanel?: boolean;
|
|
@@ -54,7 +61,7 @@ export const SkeletonRow: React.FC<SkeletonRowProps> = ({
|
|
|
54
61
|
|
|
55
62
|
{ }
|
|
56
63
|
{columns.map((col, index) => {
|
|
57
|
-
const width = col.width
|
|
64
|
+
const width = typeof col.width === 'number' ? col.width : 150;
|
|
58
65
|
const skeletonWidth = Math.min(width * 0.7, width - 20);
|
|
59
66
|
|
|
60
67
|
return (
|
|
@@ -34,7 +34,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
34
34
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
35
|
// Keep onChange always current without adding it to effect deps
|
|
36
36
|
const onChangeRef = useRef(onChange);
|
|
37
|
-
onChangeRef.current = onChange;
|
|
37
|
+
useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
|
38
38
|
// Track the last value we emitted upward
|
|
39
39
|
const lastEmittedRef = useRef(value);
|
|
40
40
|
// Track the last incoming prop value to detect external resets
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types';
|
|
4
4
|
import { PivotPanel, PivotIcon } from './PivotPanel';
|
|
@@ -23,6 +23,7 @@ export interface GridToolbarProps {
|
|
|
23
23
|
columnVisibilityModel?: Record<string, boolean>;
|
|
24
24
|
onColumnVisibilityModelChange?: (model: Record<string, boolean>) => void;
|
|
25
25
|
onColumnReorder?: (fromField: string, toField: string) => void;
|
|
26
|
+
onColumnOrderReset?: () => void;
|
|
26
27
|
forceColumnsOpen?: boolean;
|
|
27
28
|
onColumnsPanelClose?: () => void;
|
|
28
29
|
children?: React.ReactNode;
|
|
@@ -87,6 +88,11 @@ function AggregationPanel({
|
|
|
87
88
|
}) {
|
|
88
89
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
89
90
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
91
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
92
|
+
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
95
|
+
}, [anchorRef]);
|
|
90
96
|
|
|
91
97
|
const computePos = useCallback((): PanelPosition => {
|
|
92
98
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -185,7 +191,7 @@ function AggregationPanel({
|
|
|
185
191
|
</div>
|
|
186
192
|
);
|
|
187
193
|
|
|
188
|
-
return ReactDOM.createPortal(panel,
|
|
194
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
function ColumnsPanelWrapper({
|
|
@@ -196,6 +202,7 @@ function ColumnsPanelWrapper({
|
|
|
196
202
|
onShowAll,
|
|
197
203
|
onHideAll,
|
|
198
204
|
onColumnReorder,
|
|
205
|
+
onColumnOrderReset,
|
|
199
206
|
onClose
|
|
200
207
|
}: {
|
|
201
208
|
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
|
@@ -205,10 +212,16 @@ function ColumnsPanelWrapper({
|
|
|
205
212
|
onShowAll: () => void;
|
|
206
213
|
onHideAll: () => void;
|
|
207
214
|
onColumnReorder?: (fromField: string, toField: string) => void;
|
|
215
|
+
onColumnOrderReset?: () => void;
|
|
208
216
|
onClose: () => void;
|
|
209
217
|
}) {
|
|
210
218
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
211
219
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
220
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
221
|
+
|
|
222
|
+
useLayoutEffect(() => {
|
|
223
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
224
|
+
}, [anchorRef]);
|
|
212
225
|
|
|
213
226
|
const computePos = useCallback((): PanelPosition => {
|
|
214
227
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -258,11 +271,12 @@ function ColumnsPanelWrapper({
|
|
|
258
271
|
onShowAll={onShowAll}
|
|
259
272
|
onHideAll={onHideAll}
|
|
260
273
|
onColumnReorder={onColumnReorder}
|
|
274
|
+
onColumnOrderReset={onColumnOrderReset}
|
|
261
275
|
/>
|
|
262
276
|
</div>
|
|
263
277
|
);
|
|
264
278
|
|
|
265
|
-
return ReactDOM.createPortal(panel,
|
|
279
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
function FilterPanelWrapper({
|
|
@@ -284,7 +298,12 @@ function FilterPanelWrapper({
|
|
|
284
298
|
// tearing down and re-registering on every filterModel update,
|
|
285
299
|
// which could fire the close callback during the re-register window.
|
|
286
300
|
const onCloseRef = useRef(onClose);
|
|
287
|
-
onCloseRef.current = onClose;
|
|
301
|
+
useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]);
|
|
302
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
303
|
+
|
|
304
|
+
useLayoutEffect(() => {
|
|
305
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
306
|
+
}, [anchorRef]);
|
|
288
307
|
|
|
289
308
|
const computePos = useCallback((): PanelPosition => {
|
|
290
309
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -371,7 +390,7 @@ function FilterPanelWrapper({
|
|
|
371
390
|
</div>
|
|
372
391
|
);
|
|
373
392
|
|
|
374
|
-
return ReactDOM.createPortal(panel,
|
|
393
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
375
394
|
}
|
|
376
395
|
|
|
377
396
|
const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
|
|
@@ -388,6 +407,7 @@ export function GridToolbar({
|
|
|
388
407
|
columnVisibilityModel = {},
|
|
389
408
|
onColumnVisibilityModelChange,
|
|
390
409
|
onColumnReorder,
|
|
410
|
+
onColumnOrderReset,
|
|
391
411
|
forceColumnsOpen,
|
|
392
412
|
onColumnsPanelClose,
|
|
393
413
|
children,
|
|
@@ -538,6 +558,7 @@ export function GridToolbar({
|
|
|
538
558
|
onShowAll={handleShowAllColumns}
|
|
539
559
|
onHideAll={handleHideAllColumns}
|
|
540
560
|
onColumnReorder={onColumnReorder}
|
|
561
|
+
onColumnOrderReset={onColumnOrderReset}
|
|
541
562
|
onClose={() => { setColsOpen(false); onColumnsPanelClose?.(); }}
|
|
542
563
|
/>
|
|
543
564
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef } from '../../types';
|
|
4
4
|
import type { GridPivotModel, GridPivotAggFn, GridPivotValueField } from '../../types';
|
|
@@ -31,6 +31,11 @@ export interface PivotPanelProps {
|
|
|
31
31
|
export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: PivotPanelProps) {
|
|
32
32
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
33
|
const [pos, setPos] = useState({ top: 0, right: 0 });
|
|
34
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
35
|
+
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
38
|
+
}, [anchorRef]);
|
|
34
39
|
|
|
35
40
|
const computePos = useCallback(() => {
|
|
36
41
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -235,5 +240,5 @@ export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: Piv
|
|
|
235
240
|
</div>
|
|
236
241
|
);
|
|
237
242
|
|
|
238
|
-
return ReactDOM.createPortal(panel,
|
|
243
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
239
244
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type DragHandleIconProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
4
4
|
|
|
5
5
|
export const DragHandleIcon: React.FC<DragHandleIconProps> = ({ className = '', ...props }) => {
|
|
6
6
|
return (
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for bugs fixed in the useDataGrid reducer.
|
|
3
|
+
*
|
|
4
|
+
* Each describe block names the bug and the commit that fixed it, so a
|
|
5
|
+
* future failure tells you exactly what regressed and where to look.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { renderHook, act } from '@testing-library/react';
|
|
10
|
+
import { useDataGrid } from './useDataGrid';
|
|
11
|
+
import type { GridColDef } from '../../types';
|
|
12
|
+
|
|
13
|
+
const COLS: GridColDef[] = [
|
|
14
|
+
{ field: 'id', headerName: 'ID', width: 80 },
|
|
15
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
16
|
+
{ field: 'age', headerName: 'Age', width: 100 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const ROWS = [
|
|
20
|
+
{ id: 1, name: 'Alice', age: 30 },
|
|
21
|
+
{ id: 2, name: 'Bob', age: 25 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Bug: SET_COLUMNS reducer was hardcoding columnVisibilityModel: {} on every
|
|
26
|
+
// column update, silently wiping any hidden columns.
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
describe('SET_COLUMNS preserves columnVisibilityModel', () => {
|
|
29
|
+
it('keeps hidden columns hidden after setColumns is called', () => {
|
|
30
|
+
const { result } = renderHook(() =>
|
|
31
|
+
useDataGrid({
|
|
32
|
+
rows: ROWS,
|
|
33
|
+
columns: COLS,
|
|
34
|
+
columnVisibilityModel: { age: false },
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Confirm the initial visibility model was applied.
|
|
39
|
+
expect(result.current.state.columns.columnVisibilityModel).toEqual({ age: false });
|
|
40
|
+
|
|
41
|
+
// Simulate a column update (e.g. a width change coming from a resize handler).
|
|
42
|
+
const updatedCols: GridColDef[] = COLS.map(c =>
|
|
43
|
+
c.field === 'name' ? { ...c, width: 200 } : c
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
result.current.setColumns(updatedCols);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The visibility model must survive the column update — not reset to {}.
|
|
51
|
+
expect(result.current.state.columns.columnVisibilityModel).toEqual({ age: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not gain a visibility entry for columns that were already visible', () => {
|
|
55
|
+
const { result } = renderHook(() =>
|
|
56
|
+
useDataGrid({ rows: ROWS, columns: COLS })
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.setColumns(COLS);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// No spurious entries: an empty model means all columns are visible.
|
|
64
|
+
expect(result.current.state.columns.columnVisibilityModel).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
|
-
import { useReducer, useCallback,
|
|
2
|
+
import { useReducer, useCallback, useState, useRef, useEffect, useLayoutEffect } from 'react';
|
|
3
3
|
import type {
|
|
4
|
-
GridState,
|
|
4
|
+
GridInternalState as GridState,
|
|
5
5
|
GridRowModel,
|
|
6
6
|
GridColDef,
|
|
7
7
|
GridRowId,
|
|
@@ -19,27 +19,25 @@ type GridAction =
|
|
|
19
19
|
| { type: 'SET_FILTER_MODEL'; payload: GridFilterModel }
|
|
20
20
|
| { type: 'SET_PAGINATION_MODEL'; payload: GridPaginationModel }
|
|
21
21
|
| { type: 'SET_SELECTION'; payload: Set<GridRowId> }
|
|
22
|
-
| { type: 'SET_SCROLL'; payload: { scrollTop: number; scrollLeft: number } }
|
|
23
22
|
| { type: 'SET_DIMENSIONS'; payload: { viewportWidth: number; viewportHeight: number } }
|
|
24
23
|
| { type: 'SET_DATASOURCE_LOADING'; payload: boolean }
|
|
25
|
-
| { type: 'SET_DATASOURCE_ERROR'; payload:
|
|
24
|
+
| { type: 'SET_DATASOURCE_ERROR'; payload: unknown }
|
|
26
25
|
| { type: 'SET_ROW_COUNT'; payload: number };
|
|
27
26
|
|
|
28
|
-
function createInitialState(rows:
|
|
27
|
+
function createInitialState<R extends GridRowModel>(rows: R[], columns: GridColDef<R>[], columnVisibilityModel: GridColumnVisibilityModel = {}): GridState {
|
|
29
28
|
const idRowsLookup = new Map<GridRowId, GridRowModel>();
|
|
30
29
|
const allRows: GridRowId[] = [];
|
|
31
30
|
|
|
32
31
|
rows.forEach(row => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
allRows.push(id);
|
|
32
|
+
idRowsLookup.set(row.id, row);
|
|
33
|
+
allRows.push(row.id);
|
|
36
34
|
});
|
|
37
35
|
|
|
38
36
|
const columnLookup = new Map<string, GridColDef>();
|
|
39
37
|
const orderedFields: string[] = [];
|
|
40
38
|
|
|
41
39
|
columns.forEach(col => {
|
|
42
|
-
columnLookup.set(col.field, col);
|
|
40
|
+
columnLookup.set(col.field, col as unknown as GridColDef);
|
|
43
41
|
orderedFields.push(col.field);
|
|
44
42
|
});
|
|
45
43
|
|
|
@@ -49,7 +47,7 @@ function createInitialState(rows: GridRowModel[], columns: GridColDef[], columnV
|
|
|
49
47
|
allRows
|
|
50
48
|
},
|
|
51
49
|
columns: {
|
|
52
|
-
all: columns,
|
|
50
|
+
all: columns as unknown as GridColDef[],
|
|
53
51
|
lookup: columnLookup,
|
|
54
52
|
orderedFields,
|
|
55
53
|
columnVisibilityModel
|
|
@@ -78,8 +76,6 @@ function createInitialState(rows: GridRowModel[], columns: GridColDef[], columnV
|
|
|
78
76
|
firstColumnIndex: 0,
|
|
79
77
|
lastColumnIndex: 0
|
|
80
78
|
},
|
|
81
|
-
scrollTop: 0,
|
|
82
|
-
scrollLeft: 0
|
|
83
79
|
},
|
|
84
80
|
dimensions: {
|
|
85
81
|
rowHeight: 52,
|
|
@@ -137,7 +133,7 @@ function gridReducer(state: GridState, action: GridAction): GridState {
|
|
|
137
133
|
all: action.payload,
|
|
138
134
|
lookup: columnLookup,
|
|
139
135
|
orderedFields,
|
|
140
|
-
columnVisibilityModel:
|
|
136
|
+
columnVisibilityModel: state.columns.columnVisibilityModel
|
|
141
137
|
}
|
|
142
138
|
};
|
|
143
139
|
}
|
|
@@ -169,16 +165,6 @@ function gridReducer(state: GridState, action: GridAction): GridState {
|
|
|
169
165
|
selection: { selectedRows: action.payload }
|
|
170
166
|
};
|
|
171
167
|
|
|
172
|
-
case 'SET_SCROLL':
|
|
173
|
-
return {
|
|
174
|
-
...state,
|
|
175
|
-
virtualization: {
|
|
176
|
-
...state.virtualization,
|
|
177
|
-
scrollTop: action.payload.scrollTop,
|
|
178
|
-
scrollLeft: action.payload.scrollLeft
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
|
|
182
168
|
case 'SET_DIMENSIONS':
|
|
183
169
|
return {
|
|
184
170
|
...state,
|
|
@@ -234,18 +220,17 @@ export interface UseDataGridParams<R extends GridRowModel = GridRowModel> {
|
|
|
234
220
|
export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDataGridParams<R>) {
|
|
235
221
|
const { rows, columns, rowHeight = 52, headerHeight = 56, columnVisibilityModel, initialState: propInitialState } = params;
|
|
236
222
|
|
|
237
|
-
const internalInitialState =
|
|
238
|
-
|
|
239
|
-
[]
|
|
223
|
+
const [internalInitialState] = useState(() =>
|
|
224
|
+
createInitialState(rows, columns, columnVisibilityModel)
|
|
240
225
|
);
|
|
241
226
|
|
|
242
227
|
const [state, dispatch] = useReducer(gridReducer, {
|
|
243
228
|
...internalInitialState,
|
|
244
229
|
sorting: propInitialState?.sorting || internalInitialState.sorting,
|
|
245
230
|
filter: propInitialState?.filter || internalInitialState.filter,
|
|
246
|
-
dataSource:
|
|
231
|
+
dataSource: propInitialState?.dataSource
|
|
247
232
|
? { ...internalInitialState.dataSource, ...propInitialState.dataSource }
|
|
248
|
-
: internalInitialState.dataSource
|
|
233
|
+
: internalInitialState.dataSource,
|
|
249
234
|
pagination: {
|
|
250
235
|
...internalInitialState.pagination,
|
|
251
236
|
...(propInitialState?.pagination || {}),
|
|
@@ -259,7 +244,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
259
244
|
});
|
|
260
245
|
|
|
261
246
|
const stateRef = useRef(state);
|
|
262
|
-
stateRef.current = state;
|
|
247
|
+
useLayoutEffect(() => { stateRef.current = state; });
|
|
263
248
|
|
|
264
249
|
// Sync internal state when the external `rows` prop changes
|
|
265
250
|
const prevRowsRef = useRef(rows);
|
|
@@ -294,10 +279,6 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
294
279
|
dispatch({ type: 'SET_SELECTION', payload: selectedRows });
|
|
295
280
|
}, []);
|
|
296
281
|
|
|
297
|
-
const setScroll = useCallback((scrollTop: number, scrollLeft: number) => {
|
|
298
|
-
dispatch({ type: 'SET_SCROLL', payload: { scrollTop, scrollLeft } });
|
|
299
|
-
}, []);
|
|
300
|
-
|
|
301
282
|
const setDimensions = useCallback((viewportWidth: number, viewportHeight: number) => {
|
|
302
283
|
dispatch({ type: 'SET_DIMENSIONS', payload: { viewportWidth, viewportHeight } });
|
|
303
284
|
}, []);
|
|
@@ -306,7 +287,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
306
287
|
dispatch({ type: 'SET_DATASOURCE_LOADING', payload: loading });
|
|
307
288
|
}, []);
|
|
308
289
|
|
|
309
|
-
const setDataSourceError = useCallback((error:
|
|
290
|
+
const setDataSourceError = useCallback((error: unknown) => {
|
|
310
291
|
dispatch({ type: 'SET_DATASOURCE_ERROR', payload: error });
|
|
311
292
|
}, []);
|
|
312
293
|
|
|
@@ -383,7 +364,6 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
383
364
|
setFilterModel,
|
|
384
365
|
setPaginationModel,
|
|
385
366
|
setSelection,
|
|
386
|
-
setScroll,
|
|
387
367
|
setDimensions,
|
|
388
368
|
setDataSourceLoading,
|
|
389
369
|
setDataSourceError,
|