@opencorestack/opengridx 3.3.0 → 3.5.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 +64 -0
- package/README.md +10 -1
- package/dist/ai.cjs +2 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +366 -0
- package/dist/ai.es.js +2 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +396 -6
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +8029 -6090
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -14
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +182 -4
- package/docs/README.md +5 -1
- package/docs/architecture/datagrid-orchestration.md +19 -5
- package/docs/components/filter-panel.md +1 -0
- package/docs/components/header.md +4 -1
- package/docs/components/toolbar.md +7 -0
- package/docs/customization/theming.md +12 -1
- package/docs/features/ai-toolkit.md +213 -0
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/clipboard.md +88 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/fill-handle.md +125 -0
- package/docs/features/filtering.md +3 -0
- package/docs/features/header-filters.md +138 -0
- package/docs/features/keyboard-navigation.md +31 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +33 -0
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +152 -0
- package/lib/ai/validate.ts +296 -0
- package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
- package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
- package/lib/components/Cell/Cell.css +33 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +138 -5
- package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
- package/lib/components/Header/Header.tsx +44 -23
- package/lib/components/Header/headerColumnStyle.ts +28 -0
- package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/components/ui/Checkbox.tsx +16 -5
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +107 -13
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridEditing.ts +24 -45
- package/lib/index.ts +10 -0
- package/lib/styles/opengridx.css +4 -1
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +343 -1
- package/lib/utils/aiAssistant.ts +205 -0
- package/lib/utils/aiBrand.ts +17 -0
- package/lib/utils/cellSelection.ts +25 -5
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/filtering/index.ts +14 -0
- package/lib/utils/focus/index.ts +9 -3
- package/lib/utils/headerFilters.ts +189 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +97 -3
- package/package.json +8 -2
|
@@ -116,6 +116,13 @@ export interface UseGridCellSelectionReturn {
|
|
|
116
116
|
reportFocus: (cell: FocusedCell | null) => void;
|
|
117
117
|
/** Sets the model, keeping only the first range, unless it is unchanged. */
|
|
118
118
|
setModel: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
|
|
119
|
+
/**
|
|
120
|
+
* The current range (a pending model included) and where it lies, read at call time. Its indices
|
|
121
|
+
* address the renderable rows and the range columns of `navigationColumns` (`isRangeColumnField`).
|
|
122
|
+
*/
|
|
123
|
+
getCurrentRange: () => { range: GridCellSelectionModel[number] | null; resolved: ResolvedCellRange | null };
|
|
124
|
+
/** Starts a tracked pointer drag with the range drag's auto-scroll (fill handle). Stable. @since v3.5 */
|
|
125
|
+
startTrackedDrag: (options: GridTrackedDragOptions) => void;
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
interface DragState {
|
|
@@ -125,7 +132,24 @@ interface DragState {
|
|
|
125
132
|
y: number;
|
|
126
133
|
frame: number;
|
|
127
134
|
moved: boolean;
|
|
128
|
-
|
|
135
|
+
/** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
|
|
136
|
+
stop: (cancelled?: boolean, altKey?: boolean) => void;
|
|
137
|
+
/** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
|
|
138
|
+
onTrack?: (head: GridCellCoordinates, x: number, y: number) => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A drag that reuses the range drag's hit testing and auto-scroll without selecting (the fill handle, v3.5). */
|
|
142
|
+
export interface GridTrackedDragOptions {
|
|
143
|
+
/** The cell the drag starts on. */
|
|
144
|
+
start: GridCellCoordinates;
|
|
145
|
+
x: number;
|
|
146
|
+
y: number;
|
|
147
|
+
/** Pointer events of this pointer are followed (touch included), not mouse events. */
|
|
148
|
+
pointerId: number;
|
|
149
|
+
/** Each frame: the cell under the pointer (or nearest it) and the pointer position. */
|
|
150
|
+
onTrack: (head: GridCellCoordinates, x: number, y: number) => void;
|
|
151
|
+
/** Once: released (`cancelled: false`, with the Alt key state) or cancelled. */
|
|
152
|
+
onEnd: (details: { cancelled: boolean; altKey: boolean }) => void;
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
/** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
|
|
@@ -392,6 +416,21 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
392
416
|
return field === undefined ? null : { id, field };
|
|
393
417
|
}, []);
|
|
394
418
|
|
|
419
|
+
/** The row whose band holds `centerY` (px from the top of the unpinned rows), with `field`. */
|
|
420
|
+
const headRowAtOffset = useCallback((centerY: number, field: string): GridCellCoordinates | null => {
|
|
421
|
+
const { context: ctx, layout, pinnedTopRowCount } = latestRef.current;
|
|
422
|
+
const heights = layout.cumulativeHeights;
|
|
423
|
+
if (!ctx || heights.length === 0 || centerY < 0) return null;
|
|
424
|
+
let lo = 0;
|
|
425
|
+
let hi = heights.length - 1;
|
|
426
|
+
while (lo < hi) {
|
|
427
|
+
const mid = (lo + hi) >> 1;
|
|
428
|
+
if (heights[mid] > centerY) hi = mid; else lo = mid + 1;
|
|
429
|
+
}
|
|
430
|
+
const id = ctx.rowIds[lo + pinnedTopRowCount];
|
|
431
|
+
return id === undefined ? null : { id, field };
|
|
432
|
+
}, []);
|
|
433
|
+
|
|
395
434
|
// The auto-scroll loop schedules the next frame through this ref: a callback cannot name itself.
|
|
396
435
|
const runDragFrameRef = useRef<(allowScroll: boolean) => void>(() => {});
|
|
397
436
|
const runDragFrame = useCallback((allowScroll: boolean) => {
|
|
@@ -416,7 +455,14 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
416
455
|
const x = clamp(drag.x, box.left + 1, box.left + width - 2);
|
|
417
456
|
let y = drag.y;
|
|
418
457
|
if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
|
|
419
|
-
|
|
458
|
+
// During fast auto-scroll the rows under the pointer may not be rendered yet (virtualization
|
|
459
|
+
// lags a frame or more on a slow machine), so the hit test finds no cell. Work out the row from
|
|
460
|
+
// the scroll position and the row heights instead, keeping the column the head already has.
|
|
461
|
+
const canHitTest = typeof viewport.ownerDocument.elementFromPoint === 'function';
|
|
462
|
+
const hit = headAtPoint(viewport, x, y);
|
|
463
|
+
const head = hit ?? (canHitTest && drag.moved
|
|
464
|
+
? headRowAtOffset(y - box.top - (stickyTop ? stickyTop.getBoundingClientRect().height : 0) + viewport.scrollTop, drag.head.field)
|
|
465
|
+
: null);
|
|
420
466
|
if (head && !isSameCoordinates(head, drag.head)) {
|
|
421
467
|
drag.head = head;
|
|
422
468
|
if (!drag.moved && !isSameCoordinates(head, drag.anchor)) {
|
|
@@ -424,8 +470,10 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
424
470
|
latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
|
|
425
471
|
clearTextSelection(viewport.ownerDocument);
|
|
426
472
|
}
|
|
427
|
-
setRange(drag.anchor, head, 'pointer');
|
|
473
|
+
if (!drag.onTrack) setRange(drag.anchor, head, 'pointer');
|
|
428
474
|
}
|
|
475
|
+
// A tracked drag hears every frame: its target can change while the head stays in one cell.
|
|
476
|
+
if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
|
|
429
477
|
|
|
430
478
|
if (!allowScroll) return;
|
|
431
479
|
const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < box.left || drag.x > box.left + width;
|
|
@@ -443,16 +491,27 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
443
491
|
if (canScrollX) viewport.scrollLeft += dx;
|
|
444
492
|
// Keep going while the pointer rests in the edge zone; the next frame picks the new head.
|
|
445
493
|
drag.frame = requestAnimationFrame(() => runDragFrameRef.current(true));
|
|
446
|
-
}, [headAtPoint, setRange]);
|
|
494
|
+
}, [headAtPoint, headRowAtOffset, setRange]);
|
|
447
495
|
useLayoutEffect(() => {
|
|
448
496
|
runDragFrameRef.current = runDragFrame;
|
|
449
497
|
}, [runDragFrame]);
|
|
450
498
|
|
|
451
|
-
const startDrag = useCallback((
|
|
499
|
+
const startDrag = useCallback((
|
|
500
|
+
anchor: GridCellCoordinates,
|
|
501
|
+
head: GridCellCoordinates,
|
|
502
|
+
x: number,
|
|
503
|
+
y: number,
|
|
504
|
+
doc: Document,
|
|
505
|
+
tracked?: Pick<GridTrackedDragOptions, 'pointerId' | 'onTrack' | 'onEnd'>,
|
|
506
|
+
) => {
|
|
452
507
|
dragRef.current?.stop();
|
|
508
|
+
// A range drag follows the mouse; a tracked drag (fill handle) follows its pointer, touch included.
|
|
509
|
+
const moveType = tracked ? 'pointermove' : 'mousemove';
|
|
510
|
+
const upType = tracked ? 'pointerup' : 'mouseup';
|
|
511
|
+
const isOwnEvent = (event: MouseEvent) => !tracked || (event as PointerEvent).pointerId === tracked.pointerId;
|
|
453
512
|
const onMove = (event: MouseEvent) => {
|
|
454
513
|
const drag = dragRef.current;
|
|
455
|
-
if (!drag) return;
|
|
514
|
+
if (!drag || !isOwnEvent(event)) return;
|
|
456
515
|
if (event.buttons === 0) {
|
|
457
516
|
// Released outside the window.
|
|
458
517
|
drag.stop();
|
|
@@ -470,12 +529,12 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
470
529
|
};
|
|
471
530
|
const onUp = (event: MouseEvent) => {
|
|
472
531
|
const drag = dragRef.current;
|
|
473
|
-
if (!drag) return;
|
|
532
|
+
if (!drag || !isOwnEvent(event)) return;
|
|
474
533
|
drag.x = event.clientX;
|
|
475
534
|
drag.y = event.clientY;
|
|
476
535
|
runDragFrame(false);
|
|
477
536
|
const moved = drag.moved;
|
|
478
|
-
drag.stop();
|
|
537
|
+
drag.stop(false, event.altKey);
|
|
479
538
|
if (moved) clearTextSelection(doc);
|
|
480
539
|
if (moved) {
|
|
481
540
|
// The click that follows lands on the common ancestor of both cells (often the row):
|
|
@@ -484,26 +543,57 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
484
543
|
setTimeout(() => { suppressClickRef.current = false; }, 0);
|
|
485
544
|
}
|
|
486
545
|
};
|
|
546
|
+
const onCancel = (event: Event) => {
|
|
547
|
+
if (isOwnEvent(event as MouseEvent)) dragRef.current?.stop();
|
|
548
|
+
};
|
|
549
|
+
// Escape abandons a tracked drag (the fill writes nothing).
|
|
550
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
551
|
+
if (event.key !== 'Escape' || !dragRef.current) return;
|
|
552
|
+
event.preventDefault();
|
|
553
|
+
event.stopPropagation();
|
|
554
|
+
dragRef.current.stop();
|
|
555
|
+
};
|
|
487
556
|
const view = doc.defaultView;
|
|
488
557
|
const onBlur = () => dragRef.current?.stop();
|
|
558
|
+
let ended = false;
|
|
489
559
|
const drag: DragState = {
|
|
490
560
|
anchor, head, x, y, frame: 0, moved: false,
|
|
491
|
-
|
|
561
|
+
onTrack: tracked?.onTrack,
|
|
562
|
+
stop: (cancelled = true, altKey = false) => {
|
|
492
563
|
if (drag.frame) cancelAnimationFrame(drag.frame);
|
|
493
564
|
drag.frame = 0;
|
|
494
|
-
doc.removeEventListener(
|
|
495
|
-
doc.removeEventListener(
|
|
565
|
+
doc.removeEventListener(moveType, onMove, true);
|
|
566
|
+
doc.removeEventListener(upType, onUp, true);
|
|
567
|
+
if (tracked) {
|
|
568
|
+
doc.removeEventListener('pointercancel', onCancel, true);
|
|
569
|
+
doc.removeEventListener('keydown', onKeyDown, true);
|
|
570
|
+
}
|
|
496
571
|
view?.removeEventListener('blur', onBlur);
|
|
497
572
|
latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
|
|
498
573
|
if (dragRef.current === drag) dragRef.current = null;
|
|
574
|
+
if (!ended) {
|
|
575
|
+
ended = true;
|
|
576
|
+
if (tracked) tracked.onEnd({ cancelled, altKey });
|
|
577
|
+
}
|
|
499
578
|
},
|
|
500
579
|
};
|
|
501
580
|
dragRef.current = drag;
|
|
502
|
-
doc.addEventListener(
|
|
503
|
-
doc.addEventListener(
|
|
581
|
+
doc.addEventListener(moveType, onMove, true);
|
|
582
|
+
doc.addEventListener(upType, onUp, true);
|
|
583
|
+
if (tracked) {
|
|
584
|
+
doc.addEventListener('pointercancel', onCancel, true);
|
|
585
|
+
doc.addEventListener('keydown', onKeyDown, true);
|
|
586
|
+
}
|
|
504
587
|
view?.addEventListener('blur', onBlur);
|
|
505
588
|
}, [runDragFrame]);
|
|
506
589
|
|
|
590
|
+
const startTrackedDrag = useCallback((options: GridTrackedDragOptions) => {
|
|
591
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
592
|
+
if (!viewport || !latestRef.current.enabled) return;
|
|
593
|
+
const { start, x, y, pointerId, onTrack, onEnd } = options;
|
|
594
|
+
startDrag(start, start, x, y, viewport.ownerDocument, { pointerId, onTrack, onEnd });
|
|
595
|
+
}, [startDrag]);
|
|
596
|
+
|
|
507
597
|
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
508
598
|
if (event.button !== 0 || event.defaultPrevented) return;
|
|
509
599
|
const { context: ctx } = latestRef.current;
|
|
@@ -511,6 +601,8 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
511
601
|
const viewport = event.currentTarget;
|
|
512
602
|
const hit = cellFromTarget(event.target, viewport);
|
|
513
603
|
if (!hit || !isRangeColumnField(hit.field)) return;
|
|
604
|
+
// The fill handle starts its own drag (useGridFillHandle).
|
|
605
|
+
if ((event.target as Element).closest?.('.ogx__cell-fill-handle')) return;
|
|
514
606
|
// Typing targets and open editors keep the pointer to themselves.
|
|
515
607
|
if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
|
|
516
608
|
const id = ctx.rowIds[hit.rowIndex];
|
|
@@ -598,6 +690,8 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
598
690
|
stats,
|
|
599
691
|
reportFocus,
|
|
600
692
|
setModel,
|
|
693
|
+
getCurrentRange: currentRange,
|
|
694
|
+
startTrackedDrag,
|
|
601
695
|
};
|
|
602
696
|
}
|
|
603
697
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import type { MutableRefObject, RefObject } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
GridApi,
|
|
5
|
+
GridBatchEditResult,
|
|
6
|
+
GridBeforeClipboardPasteParams,
|
|
7
|
+
GridCellCoordinates,
|
|
8
|
+
GridCellParams,
|
|
9
|
+
GridClipboardPasteResult,
|
|
10
|
+
GridColDef,
|
|
11
|
+
GridEditSkipReason,
|
|
12
|
+
GridRowId,
|
|
13
|
+
GridRowMeta,
|
|
14
|
+
GridRowModel,
|
|
15
|
+
} from '../../types';
|
|
16
|
+
import { attempt } from '../../utils/attempt';
|
|
17
|
+
import { classifyKeyTarget } from '../../utils/focus';
|
|
18
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
19
|
+
import { isRangeColumnField, rectCellCount } from '../../utils/cellSelection';
|
|
20
|
+
import type { CellRangeRect } from '../../utils/cellSelection';
|
|
21
|
+
import { parseCellText, parseTsv } from '../../utils/parsing';
|
|
22
|
+
import { getCellValue, warnCallbackFailure } from '../../utils/values';
|
|
23
|
+
import {
|
|
24
|
+
buildEditTargetContext,
|
|
25
|
+
isEditTargetWritable,
|
|
26
|
+
isTextColumn,
|
|
27
|
+
rectCorners,
|
|
28
|
+
} from '../../utils/editing/targets';
|
|
29
|
+
import type { GridEditTargetContext } from '../../utils/editing/targets';
|
|
30
|
+
import type { GridBatchCellEdit, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
|
|
31
|
+
import type { UseGridCellSelectionReturn } from './useGridCellSelection';
|
|
32
|
+
|
|
33
|
+
/** Above this many cells a paste warns in development. */
|
|
34
|
+
export const LARGE_PASTE_CELLS = 10_000;
|
|
35
|
+
|
|
36
|
+
const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
|
|
37
|
+
|
|
38
|
+
export interface UseGridClipboardPasteParams<R extends GridRowModel> {
|
|
39
|
+
apiRef: MutableRefObject<GridApi>;
|
|
40
|
+
/** The grid root: paste and Delete are listened for here. */
|
|
41
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
42
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
43
|
+
selection: UseGridCellSelectionReturn;
|
|
44
|
+
disableClipboardPaste: boolean;
|
|
45
|
+
disableRangeClear: boolean;
|
|
46
|
+
focusedCell: FocusedCell | null;
|
|
47
|
+
allRenderableRows: R[];
|
|
48
|
+
getRowId: (row: R) => GridRowId;
|
|
49
|
+
/** System columns and the rendered data columns, in render order. */
|
|
50
|
+
navigationColumns: ReadonlyArray<GridColDef<R>>;
|
|
51
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
52
|
+
rowMetaMap: Map<GridRowId, GridRowMeta>;
|
|
53
|
+
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
54
|
+
/** Whether a cell editor is open: paste and Delete then belong to it. */
|
|
55
|
+
isEditing: boolean;
|
|
56
|
+
batchEdit: UseGridBatchEditReturn<R>;
|
|
57
|
+
onClipboardPaste?: (result: GridClipboardPasteResult) => void;
|
|
58
|
+
onBeforeClipboardPaste?: (params: GridBeforeClipboardPasteParams) => boolean | string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function warnLargePaste(cellCount: number): void {
|
|
62
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
63
|
+
console.warn(`[OpenGridX] Pasting ${cellCount} cells. Pastes above ${LARGE_PASTE_CELLS} cells can be slow; consider importing the data instead.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clipboard paste and range clear (v3.4). With `cellSelection` on: Ctrl/Cmd+V (the `paste` event,
|
|
68
|
+
* so no clipboard permission is needed) writes tab-separated text into the editable cells, and
|
|
69
|
+
* Delete / Backspace on a multi-cell range empties them. Both are one batch edit (`useGridBatchEdit`),
|
|
70
|
+
* so one undo. Installs `apiRef.pasteText`. Runs after useGridCellSelectionApi.
|
|
71
|
+
*/
|
|
72
|
+
export function useGridClipboardPaste<R extends GridRowModel>(params: UseGridClipboardPasteParams<R>): void {
|
|
73
|
+
const { apiRef, containerRef, selection, disableClipboardPaste, disableRangeClear } = params;
|
|
74
|
+
const latestRef = useRef(params);
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
latestRef.current = params;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const getContext = useCallback((): GridEditTargetContext<R> => {
|
|
80
|
+
const { allRenderableRows, getRowId, navigationColumns } = latestRef.current;
|
|
81
|
+
return buildEditTargetContext(allRenderableRows, getRowId, navigationColumns);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
/** The range's rectangle (display indices), or null. */
|
|
85
|
+
const currentRect = useCallback((): CellRangeRect | null => {
|
|
86
|
+
const { selection: s } = latestRef.current;
|
|
87
|
+
if (!s.enabled) return null;
|
|
88
|
+
return s.getCurrentRange().resolved;
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
/** The cell a paste starts from: the range's top-left cell, else the focused data cell. */
|
|
92
|
+
const defaultAnchor = useCallback((ctx: GridEditTargetContext<R>): GridCellCoordinates | null => {
|
|
93
|
+
const rect = currentRect();
|
|
94
|
+
if (rect && ctx.rowIds[rect.top] !== undefined && ctx.columns[rect.left]) {
|
|
95
|
+
return { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field };
|
|
96
|
+
}
|
|
97
|
+
const focus = latestRef.current.focusedCell;
|
|
98
|
+
if (focus && focus.id !== null && isRangeColumnField(focus.field)) return { id: focus.id, field: focus.field };
|
|
99
|
+
return null;
|
|
100
|
+
}, [currentRect]);
|
|
101
|
+
|
|
102
|
+
const paste = useCallback(async (clipboardText: string, anchorOverride: GridCellCoordinates | undefined, viaKeyboard: boolean): Promise<GridBatchEditResult> => {
|
|
103
|
+
const ctx = getContext();
|
|
104
|
+
const anchor = anchorOverride ?? defaultAnchor(ctx);
|
|
105
|
+
let text = clipboardText;
|
|
106
|
+
const { onBeforeClipboardPaste } = latestRef.current;
|
|
107
|
+
if (onBeforeClipboardPaste) {
|
|
108
|
+
const verdict = attempt(() => onBeforeClipboardPaste({ text, anchor }));
|
|
109
|
+
if (!verdict.ok) {
|
|
110
|
+
warnCallbackFailure('onBeforeClipboardPaste', 'onBeforeClipboardPaste threw; the paste was cancelled', verdict.error);
|
|
111
|
+
return emptyResult();
|
|
112
|
+
}
|
|
113
|
+
if (verdict.value === false) return emptyResult();
|
|
114
|
+
if (typeof verdict.value === 'string') text = verdict.value;
|
|
115
|
+
}
|
|
116
|
+
if (!anchor) return emptyResult();
|
|
117
|
+
const block = parseTsv(text);
|
|
118
|
+
const anchorRow = ctx.rowIndexById.get(anchor.id);
|
|
119
|
+
const anchorCol = ctx.colIndexByField.get(anchor.field);
|
|
120
|
+
if (block.length === 0 || anchorRow === undefined || anchorCol === undefined) return emptyResult();
|
|
121
|
+
|
|
122
|
+
const lastRow = ctx.rowIds.length - 1;
|
|
123
|
+
const lastCol = ctx.columns.length - 1;
|
|
124
|
+
// A range of several cells bounds the paste; one value fills all of it.
|
|
125
|
+
const range = anchorOverride ? null : currentRect();
|
|
126
|
+
const multiCellRange = range && rectCellCount(range) > 1 ? range : null;
|
|
127
|
+
const singleValue = block.length === 1 && block[0].length === 1;
|
|
128
|
+
const blockWidth = block.reduce((max, line) => Math.max(max, line.length), 0);
|
|
129
|
+
let rect: CellRangeRect;
|
|
130
|
+
if (singleValue && multiCellRange) {
|
|
131
|
+
rect = multiCellRange;
|
|
132
|
+
} else {
|
|
133
|
+
rect = {
|
|
134
|
+
top: anchorRow,
|
|
135
|
+
left: anchorCol,
|
|
136
|
+
bottom: Math.min(anchorRow + block.length - 1, lastRow, multiCellRange ? multiCellRange.bottom : lastRow),
|
|
137
|
+
right: Math.min(anchorCol + blockWidth - 1, lastCol, multiCellRange ? multiCellRange.right : lastCol),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const cellCount = rectCellCount(rect);
|
|
141
|
+
if (cellCount > LARGE_PASTE_CELLS) warnLargePaste(cellCount);
|
|
142
|
+
|
|
143
|
+
const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
|
|
144
|
+
const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
|
|
145
|
+
const edits: GridBatchCellEdit[] = [];
|
|
146
|
+
const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
|
|
147
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
148
|
+
const id = ctx.rowIds[r];
|
|
149
|
+
const row = batchEdit.getRow(id) ?? ctx.rows[r];
|
|
150
|
+
const line = singleValue ? block[0] : block[r - rect.top];
|
|
151
|
+
for (let c = rect.left; c <= rect.right; c++) {
|
|
152
|
+
const cellText = singleValue ? block[0][0] : line?.[c - rect.left];
|
|
153
|
+
if (cellText === undefined) continue;
|
|
154
|
+
const colDef = ctx.columns[c];
|
|
155
|
+
if (!isEditTargetWritable(ctx, rules, row, r, c)) {
|
|
156
|
+
skipped.push({ id, field: colDef.field, reason: 'notEditable' });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const parsed = parseCellText(cellText, colDef, row, { current: getCellValue(row, colDef.field, colDef) });
|
|
160
|
+
if (!parsed.ok) {
|
|
161
|
+
skipped.push({ id, field: colDef.field, reason: 'invalidValue' });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
edits.push({ id, field: colDef.field, value: parsed.value });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The pasted area becomes the selection, so one look shows what changed.
|
|
169
|
+
if (latestRef.current.selection.enabled) {
|
|
170
|
+
const { anchor: from, head: to } = rectCorners(ctx, rect);
|
|
171
|
+
latestRef.current.selection.setModel([{ anchor: from, head: to }], viaKeyboard ? 'keyboard' : 'api');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const outcome = await batchEdit.applyEdits(edits, 'paste');
|
|
175
|
+
const result: GridBatchEditResult = {
|
|
176
|
+
updated: outcome.result.updated,
|
|
177
|
+
failed: outcome.result.failed,
|
|
178
|
+
skipped: [...skipped, ...outcome.result.skipped],
|
|
179
|
+
};
|
|
180
|
+
latestRef.current.onClipboardPaste?.({ ...result, text });
|
|
181
|
+
return result;
|
|
182
|
+
}, [getContext, defaultAnchor, currentRect]);
|
|
183
|
+
|
|
184
|
+
/** Delete / Backspace: empties the editable cells of a multi-cell range. Returns whether it applied. */
|
|
185
|
+
const clearRange = useCallback((): boolean => {
|
|
186
|
+
const rect = currentRect();
|
|
187
|
+
if (!rect || rectCellCount(rect) <= 1) return false;
|
|
188
|
+
const ctx = getContext();
|
|
189
|
+
const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
|
|
190
|
+
const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
|
|
191
|
+
const edits: GridBatchCellEdit[] = [];
|
|
192
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
193
|
+
const id = ctx.rowIds[r];
|
|
194
|
+
if (id === undefined) continue;
|
|
195
|
+
const row = batchEdit.getRow(id) ?? ctx.rows[r];
|
|
196
|
+
for (let c = rect.left; c <= rect.right; c++) {
|
|
197
|
+
const colDef = ctx.columns[c];
|
|
198
|
+
if (!colDef || !isEditTargetWritable(ctx, rules, row, r, c)) continue;
|
|
199
|
+
edits.push({ id, field: colDef.field, value: isTextColumn(colDef) ? '' : null });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
void batchEdit.applyEdits(edits, 'clear');
|
|
203
|
+
return true;
|
|
204
|
+
}, [currentRect, getContext]);
|
|
205
|
+
|
|
206
|
+
const pasteEnabled = selection.enabled && !disableClipboardPaste;
|
|
207
|
+
const clearEnabled = selection.enabled && !disableRangeClear;
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const root = containerRef.current;
|
|
211
|
+
if (!root || (!pasteEnabled && !clearEnabled)) return;
|
|
212
|
+
/** Only events aimed at this grid's cells (not an editor, a text field or a nested grid). */
|
|
213
|
+
const ownsTarget = (event: Event) => {
|
|
214
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
215
|
+
if (!viewport || event.defaultPrevented || latestRef.current.isEditing) return false;
|
|
216
|
+
const kind = classifyKeyTarget(event.target, viewport);
|
|
217
|
+
return kind === 'grid' || kind === 'control';
|
|
218
|
+
};
|
|
219
|
+
// Some engines (WebKit on Linux) only fire `paste` on an editable element, never on a focused
|
|
220
|
+
// cell. On the paste shortcut, focus moves to an off-screen textarea for that one event, so the
|
|
221
|
+
// browser's paste lands there and bubbles to the root; focus then goes back to the cell.
|
|
222
|
+
let capture: { textarea: HTMLTextAreaElement; restore: HTMLElement | null; timer: number } | null = null;
|
|
223
|
+
const endCapture = () => {
|
|
224
|
+
if (!capture) return;
|
|
225
|
+
const { textarea, restore, timer } = capture;
|
|
226
|
+
capture = null;
|
|
227
|
+
window.clearTimeout(timer);
|
|
228
|
+
const hadFocus = document.activeElement === textarea;
|
|
229
|
+
textarea.remove();
|
|
230
|
+
if (hadFocus && restore && restore.isConnected) restore.focus({ preventScroll: true });
|
|
231
|
+
};
|
|
232
|
+
const startCapture = () => {
|
|
233
|
+
endCapture();
|
|
234
|
+
const textarea = document.createElement('textarea');
|
|
235
|
+
textarea.setAttribute('aria-hidden', 'true');
|
|
236
|
+
textarea.tabIndex = -1;
|
|
237
|
+
textarea.className = 'ogx__paste-capture';
|
|
238
|
+
textarea.style.cssText = 'position:fixed;top:0;left:-10000px;width:1px;height:1px;opacity:0;';
|
|
239
|
+
root.appendChild(textarea);
|
|
240
|
+
const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
241
|
+
// No paste event within the timeout (an empty or non-text clipboard): give focus back anyway.
|
|
242
|
+
capture = { textarea, restore, timer: window.setTimeout(endCapture, 250) };
|
|
243
|
+
textarea.focus({ preventScroll: true });
|
|
244
|
+
};
|
|
245
|
+
const isCaptureTarget = (event: Event) => capture !== null && event.target === capture.textarea;
|
|
246
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
247
|
+
if (isCaptureTarget(event)) {
|
|
248
|
+
const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
endCapture();
|
|
251
|
+
if (text !== '') void paste(text, undefined, true);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!pasteEnabled || !ownsTarget(event)) return;
|
|
255
|
+
if (!latestRef.current.navigationColumns.some(c => c.editable)) return;
|
|
256
|
+
const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
|
|
257
|
+
if (text === '') return;
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
void paste(text, undefined, true);
|
|
260
|
+
};
|
|
261
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
262
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && (event.key === 'v' || event.key === 'V' || event.code === 'KeyV')) {
|
|
263
|
+
if (pasteEnabled && ownsTarget(event) && latestRef.current.navigationColumns.some(c => c.editable)) startCapture();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!clearEnabled || (event.key !== 'Delete' && event.key !== 'Backspace')) return;
|
|
267
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
268
|
+
if (!ownsTarget(event)) return;
|
|
269
|
+
if (clearRange()) event.preventDefault();
|
|
270
|
+
};
|
|
271
|
+
root.addEventListener('paste', onPaste);
|
|
272
|
+
root.addEventListener('keydown', onKeyDown);
|
|
273
|
+
return () => {
|
|
274
|
+
endCapture();
|
|
275
|
+
root.removeEventListener('paste', onPaste);
|
|
276
|
+
root.removeEventListener('keydown', onKeyDown);
|
|
277
|
+
};
|
|
278
|
+
}, [containerRef, pasteEnabled, clearEnabled, paste, clearRange]);
|
|
279
|
+
|
|
280
|
+
useLayoutEffect(() => {
|
|
281
|
+
apiRef.current.pasteText = (text, anchor) => paste(text, anchor, false);
|
|
282
|
+
}, [apiRef, paste]);
|
|
283
|
+
}
|
|
@@ -16,26 +16,46 @@ export interface UseGridColumnsPanelResult {
|
|
|
16
16
|
standalonePanelRef: RefObject<HTMLDivElement | null>;
|
|
17
17
|
/** The column menu's **Manage columns** action. */
|
|
18
18
|
openColumnsPanel: () => void;
|
|
19
|
+
/** Passed to the toolbar as `forceFiltersOpen`. */
|
|
20
|
+
toolbarFiltersRequested: boolean;
|
|
21
|
+
/** Passed to the toolbar as `onFiltersPanelClose`. */
|
|
22
|
+
closeToolbarFiltersPanel: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Opens the toolbar's filter panel (a header filter cell's "Custom filter"); undefined while no
|
|
25
|
+
* `GridToolbar` that can show it is rendered.
|
|
26
|
+
*/
|
|
27
|
+
openFiltersPanel: (() => void) | undefined;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* Opens the Columns panel for the column menu: in the toolbar when a `GridToolbar` has registered
|
|
23
32
|
* through `GridToolbarHostContext`, otherwise as the standalone panel, which closes on Escape or a
|
|
24
|
-
* mousedown outside it.
|
|
33
|
+
* mousedown outside it. Also opens the toolbar's filter panel for the header filter row, when a
|
|
34
|
+
* `GridToolbar` has registered that it can show it.
|
|
25
35
|
*/
|
|
26
36
|
export function useGridColumnsPanel(): UseGridColumnsPanelResult {
|
|
27
37
|
const [toolbarPanelRequested, setToolbarPanelRequested] = useState(false);
|
|
28
38
|
const [standalonePanelOpen, setStandalonePanelOpen] = useState(false);
|
|
29
39
|
const standalonePanelRef = useRef<HTMLDivElement>(null);
|
|
30
40
|
const toolbarHostCountRef = useRef(0);
|
|
41
|
+
// State, not a ref: whether "Custom filter" can open the panel decides how it renders.
|
|
42
|
+
const [filtersHostCount, setFiltersHostCount] = useState(0);
|
|
43
|
+
const [toolbarFiltersRequested, setToolbarFiltersRequested] = useState(false);
|
|
31
44
|
|
|
32
45
|
const toolbarHost = useMemo<GridToolbarHost>(() => ({
|
|
33
46
|
registerColumnsPanel: () => {
|
|
34
47
|
toolbarHostCountRef.current += 1;
|
|
35
48
|
return () => { toolbarHostCountRef.current -= 1; };
|
|
36
49
|
},
|
|
50
|
+
registerFiltersPanel: () => {
|
|
51
|
+
setFiltersHostCount(count => count + 1);
|
|
52
|
+
return () => setFiltersHostCount(count => count - 1);
|
|
53
|
+
},
|
|
37
54
|
}), []);
|
|
38
55
|
|
|
56
|
+
const requestFiltersPanel = useCallback(() => setToolbarFiltersRequested(true), []);
|
|
57
|
+
const closeToolbarFiltersPanel = useCallback(() => setToolbarFiltersRequested(false), []);
|
|
58
|
+
|
|
39
59
|
const openColumnsPanel = useCallback(() => {
|
|
40
60
|
if (toolbarHostCountRef.current > 0) {
|
|
41
61
|
setToolbarPanelRequested(true);
|
|
@@ -73,5 +93,8 @@ export function useGridColumnsPanel(): UseGridColumnsPanelResult {
|
|
|
73
93
|
closeStandalonePanel,
|
|
74
94
|
standalonePanelRef,
|
|
75
95
|
openColumnsPanel,
|
|
96
|
+
toolbarFiltersRequested,
|
|
97
|
+
closeToolbarFiltersPanel,
|
|
98
|
+
openFiltersPanel: filtersHostCount > 0 ? requestFiltersPanel : undefined,
|
|
76
99
|
};
|
|
77
100
|
}
|
|
@@ -7,11 +7,13 @@ import type {
|
|
|
7
7
|
GridPaginationModel,
|
|
8
8
|
GridAggregationModel,
|
|
9
9
|
GridPivotModel,
|
|
10
|
+
GridRowGroupingModel,
|
|
10
11
|
GridCellSelectionModel,
|
|
11
12
|
GridCellSelectionChangeDetails,
|
|
12
13
|
GridCellSelectionReason,
|
|
13
14
|
} from '../../types';
|
|
14
15
|
import type { GridInitialState, GridDensityState } from '../../state/types';
|
|
16
|
+
import { EMPTY_ROW_GROUPING_MODEL } from '../../utils/gridModes';
|
|
15
17
|
|
|
16
18
|
export type GridDensity = GridDensityState['density'];
|
|
17
19
|
|
|
@@ -27,6 +29,9 @@ export interface UseGridControlledStateParams {
|
|
|
27
29
|
aggregationModel?: GridAggregationModel;
|
|
28
30
|
onAggregationModelChange?: (model: GridAggregationModel) => void;
|
|
29
31
|
|
|
32
|
+
rowGroupingModel?: GridRowGroupingModel;
|
|
33
|
+
onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
|
|
34
|
+
|
|
30
35
|
columnVisibilityModel?: Record<string, boolean>;
|
|
31
36
|
onColumnVisibilityModelChange?: (model: Record<string, boolean>) => void;
|
|
32
37
|
|
|
@@ -68,6 +73,11 @@ export interface UseGridControlledStateReturn {
|
|
|
68
73
|
aggregationModel: GridAggregationModel;
|
|
69
74
|
handleAggregationModelChange: (model: GridAggregationModel) => void;
|
|
70
75
|
|
|
76
|
+
// row grouping (v3.5: a controlled/uncontrolled pair)
|
|
77
|
+
rowGroupingModel: GridRowGroupingModel;
|
|
78
|
+
/** Sets the internal model when uncontrolled and fires onRowGroupingModelChange. */
|
|
79
|
+
handleRowGroupingModelChange: (model: GridRowGroupingModel) => void;
|
|
80
|
+
|
|
71
81
|
// column visibility
|
|
72
82
|
columnVisibilityModel: Record<string, boolean>;
|
|
73
83
|
handleColumnVisibilityModelChange: (model: Record<string, boolean>) => void;
|
|
@@ -131,6 +141,8 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
131
141
|
onFilterModelChange,
|
|
132
142
|
aggregationModel: propAggregationModel,
|
|
133
143
|
onAggregationModelChange,
|
|
144
|
+
rowGroupingModel: propRowGroupingModel,
|
|
145
|
+
onRowGroupingModelChange,
|
|
134
146
|
columnVisibilityModel: propColumnVisibilityModel,
|
|
135
147
|
onColumnVisibilityModelChange,
|
|
136
148
|
pinnedColumns: propPinnedColumns,
|
|
@@ -190,6 +202,18 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
190
202
|
onAggregationModelChange?.(model);
|
|
191
203
|
}, [isAggregationControlled, onAggregationModelChange]);
|
|
192
204
|
|
|
205
|
+
// ── Row grouping ─────────────────────────────────────────────────────────
|
|
206
|
+
// A passed rowGroupingModel controls grouping and is used as is (the hierarchy hooks memoise on
|
|
207
|
+
// it); without one the grid keeps its own model, changed through apiRef and the AI assistant.
|
|
208
|
+
const isRowGroupingControlled = propRowGroupingModel !== undefined;
|
|
209
|
+
const [internalRowGroupingModel, setInternalRowGroupingModel] = useState<GridRowGroupingModel>(EMPTY_ROW_GROUPING_MODEL);
|
|
210
|
+
const rowGroupingModel = (isRowGroupingControlled ? propRowGroupingModel : internalRowGroupingModel) || EMPTY_ROW_GROUPING_MODEL;
|
|
211
|
+
|
|
212
|
+
const handleRowGroupingModelChange = useCallback((model: GridRowGroupingModel) => {
|
|
213
|
+
if (!isRowGroupingControlled) setInternalRowGroupingModel(model);
|
|
214
|
+
onRowGroupingModelChange?.(model);
|
|
215
|
+
}, [isRowGroupingControlled, onRowGroupingModelChange]);
|
|
216
|
+
|
|
193
217
|
// ── Column visibility ────────────────────────────────────────────────────
|
|
194
218
|
const isColumnVisibilityControlled = propColumnVisibilityModel !== undefined;
|
|
195
219
|
const [internalColumnVisibilityModel, setInternalColumnVisibilityModel] = useState<Record<string, boolean>>(
|
|
@@ -295,6 +319,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
295
319
|
aggregationModel,
|
|
296
320
|
handleAggregationModelChange,
|
|
297
321
|
|
|
322
|
+
rowGroupingModel,
|
|
323
|
+
handleRowGroupingModelChange,
|
|
324
|
+
|
|
298
325
|
columnVisibilityModel,
|
|
299
326
|
handleColumnVisibilityModelChange,
|
|
300
327
|
|