Skip to content

Commit eefdfd3

Browse files
authoredFeb 8, 2024
Row Grouping (glideapps#898)
Adds row grouping support. This is not a complete implementation yet as it does not yet support overriding vertical borders or implement sticky headers.
1 parent c26df0e commit eefdfd3

10 files changed

+1813
-629
lines changed
 

‎packages/core/src/data-editor/data-editor.tsx

+140-27
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ import {
8181
} from "../internal/data-grid/event-args.js";
8282
import { type Keybinds, useKeybindingsWithDefaults } from "./data-editor-keybindings.js";
8383
import type { Highlight } from "../internal/data-grid/render/data-grid-render.cells.js";
84+
import { useRowGroupingInner, type RowGroupingOptions } from "./row-grouping.js";
85+
import { useRowGrouping } from "./row-grouping-api.js";
8486

8587
const DataGridOverlayEditor = React.lazy(
8688
async () => await import("../internal/data-grid-overlay-editor/data-grid-overlay-editor.js")
@@ -121,6 +123,7 @@ type Props = Partial<
121123
| "getCellContent"
122124
| "getCellRenderer"
123125
| "getCellsForSelection"
126+
| "getRowThemeOverride"
124127
| "gridRef"
125128
| "groupHeaderHeight"
126129
| "headerHeight"
@@ -443,6 +446,15 @@ export interface DataEditorProps extends Props, Pick<DataGridSearchProps, "image
443446
*/
444447
readonly markdownDivCreateNode?: (content: string) => DocumentFragment;
445448

449+
/**
450+
* Allows overriding the theme of any row
451+
* @param row represents the row index of the row, increasing by 1 for every represented row. Collapsed rows are not included.
452+
* @param groupRow represents the row index of the group row. Only distinct when row grouping enabled.
453+
* @param contentRow represents the index of the row excluding group headers. Only distinct when row grouping enabled.
454+
* @returns
455+
*/
456+
readonly getRowThemeOverride?: (row: number, groupRow: number, contentRow: number) => Partial<Theme> | undefined;
457+
446458
/** Callback for providing a custom editor for a cell.
447459
* @group Editing
448460
*/
@@ -580,6 +592,11 @@ export interface DataEditorProps extends Props, Pick<DataGridSearchProps, "image
580592
*/
581593
readonly verticalBorder?: DataGridSearchProps["verticalBorder"] | boolean;
582594

595+
/**
596+
* Controls the grouping of rows to be drawn in the grid.
597+
*/
598+
readonly rowGrouping?: RowGroupingOptions;
599+
583600
/**
584601
* Called when data is pasted into the grid. If left undefined, the `DataEditor` will operate in a
585602
* fallback mode and attempt to paste the text buffer into the current cell assuming the current cell is not
@@ -722,12 +739,12 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
722739

723740
const {
724741
imageEditorOverride,
725-
getRowThemeOverride,
742+
getRowThemeOverride: getRowThemeOverrideIn,
726743
markdownDivCreateNode,
727744
width,
728745
height,
729746
columns: columnsIn,
730-
rows,
747+
rows: rowsIn,
731748
getCellContent,
732749
onCellClicked,
733750
onCellActivated,
@@ -778,6 +795,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
778795
onHeaderMenuClick,
779796
onHeaderIndicatorClick,
780797
getGroupDetails,
798+
rowGrouping,
781799
onSearchClose: onSearchCloseIn,
782800
onItemHovered,
783801
onSelectionCleared,
@@ -847,22 +865,28 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
847865
return window.getComputedStyle(document.documentElement);
848866
}, []);
849867

850-
const remSize = React.useMemo(() => Number.parseFloat(docStyle.fontSize), [docStyle]);
868+
const {
869+
rows,
870+
rowNumberMapper,
871+
rowHeight: rowHeightPostGrouping,
872+
getRowThemeOverride,
873+
} = useRowGroupingInner(rowGrouping, rowsIn, rowHeightIn, getRowThemeOverrideIn);
851874

875+
const remSize = React.useMemo(() => Number.parseFloat(docStyle.fontSize), [docStyle]);
852876
const { rowHeight, headerHeight, groupHeaderHeight, theme, overscrollX, overscrollY } = useRemAdjuster({
853877
groupHeaderHeight: groupHeaderHeightIn,
854878
headerHeight: headerHeightIn,
855879
overscrollX: overscrollXIn,
856880
overscrollY: overscrollYIn,
857881
remSize,
858-
rowHeight: rowHeightIn,
882+
rowHeight: rowHeightPostGrouping,
859883
scaleToRem,
860884
theme: themeIn,
861885
});
862886

863887
const keybindings = useKeybindingsWithDefaults(keybindingsIn);
864888

865-
const rowMarkerWidth = rowMarkerWidthRaw ?? (rows > 10_000 ? 48 : rows > 1000 ? 44 : rows > 100 ? 36 : 32);
889+
const rowMarkerWidth = rowMarkerWidthRaw ?? (rowsIn > 10_000 ? 48 : rowsIn > 1000 ? 44 : rowsIn > 100 ? 36 : 32);
866890
const hasRowMarkers = rowMarkers !== "none";
867891
const rowMarkerOffset = hasRowMarkers ? 1 : 0;
868892
const showTrailingBlankRow = onRowAppended !== undefined;
@@ -1267,13 +1291,15 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
12671291
if (isTrailing) {
12681292
return loadingCell;
12691293
}
1294+
const mappedRow = rowNumberMapper(row);
1295+
if (mappedRow === undefined) return loadingCell;
12701296
return {
12711297
kind: InnerGridCellKind.Marker,
12721298
allowOverlay: false,
12731299
checkboxStyle: rowMarkerCheckboxStyle,
12741300
checked: gridSelection?.rows.hasIndex(row) === true,
12751301
markerKind: rowMarkers === "clickable-number" ? "number" : rowMarkers,
1276-
row: rowMarkerStartIndex + row,
1302+
row: rowMarkerStartIndex + mappedRow,
12771303
drawHandle: onRowMoved !== undefined,
12781304
cursor: rowMarkers === "clickable-number" ? "pointer" : undefined,
12791305
};
@@ -1335,6 +1361,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
13351361
showTrailingBlankRow,
13361362
mangledRows,
13371363
hasRowMarkers,
1364+
rowNumberMapper,
13381365
rowMarkerCheckboxStyle,
13391366
gridSelection?.rows,
13401367
rowMarkers,
@@ -1721,6 +1748,10 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
17211748
[getRowThemeOverride, mangledCols, mergedTheme]
17221749
);
17231750

1751+
const { mapper } = useRowGrouping(rowGrouping, rowsIn);
1752+
1753+
const rowGroupingNavBehavior = rowGrouping?.navigationBehavior;
1754+
17241755
const handleSelect = React.useCallback(
17251756
(args: GridMouseEventArgs) => {
17261757
const isMultiKey = browserIsOSX.value ? args.metaKey : args.ctrlKey;
@@ -1818,6 +1849,11 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
18181849
return;
18191850
}
18201851
}
1852+
1853+
if (rowGroupingNavBehavior === "block" && mapper(row).isGroupHeader) {
1854+
return;
1855+
}
1856+
18211857
const isLastStickyRow = lastRowSticky && row === rows;
18221858

18231859
const startedFromLastSticky =
@@ -1927,28 +1963,30 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
19271963
}
19281964
},
19291965
[
1930-
appendRow,
1966+
rowSelect,
19311967
columnSelect,
1932-
focus,
1933-
getCellRenderer,
1934-
getCustomNewRowTargetColumn,
1935-
getMangledCellContent,
19361968
gridSelection,
19371969
hasRowMarkers,
1938-
lastRowSticky,
1939-
onSelectionCleared,
1940-
onRowMoved,
19411970
rowMarkerOffset,
1971+
showTrailingBlankRow,
1972+
rows,
19421973
rowMarkers,
1943-
rowSelect,
1974+
getMangledCellContent,
1975+
onRowMoved,
1976+
focus,
19441977
rowSelectionMode,
1945-
rows,
1978+
getCellRenderer,
1979+
themeForCell,
1980+
setSelectedRows,
1981+
getCustomNewRowTargetColumn,
1982+
appendRow,
1983+
rowGroupingNavBehavior,
1984+
mapper,
1985+
lastRowSticky,
19461986
setCurrent,
1947-
setGridSelection,
19481987
setSelectedColumns,
1949-
setSelectedRows,
1950-
showTrailingBlankRow,
1951-
themeForCell,
1988+
setGridSelection,
1989+
onSelectionCleared,
19521990
]
19531991
);
19541992
const isActivelyDraggingHeader = React.useRef(false);
@@ -2542,6 +2580,27 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
25422580
isActivelyDragging.current = false;
25432581
}, []);
25442582

2583+
const rowGroupingSelectionBehavior = rowGrouping?.selectionBehavior;
2584+
2585+
const getSelectionRowLimits = React.useCallback(
2586+
(selectedRow: number): readonly [number, number] | undefined => {
2587+
if (rowGroupingSelectionBehavior !== "block-spanning") return undefined;
2588+
2589+
const { isGroupHeader, path, groupRows } = mapper(selectedRow);
2590+
2591+
if (isGroupHeader) {
2592+
return [selectedRow, selectedRow];
2593+
}
2594+
2595+
const groupRowIndex = path[path.length - 1];
2596+
const lowerBounds = selectedRow - groupRowIndex;
2597+
const upperBounds = selectedRow + groupRows - groupRowIndex - 1;
2598+
2599+
return [lowerBounds, upperBounds];
2600+
},
2601+
[mapper, rowGroupingSelectionBehavior]
2602+
);
2603+
25452604
const hoveredRef = React.useRef<GridMouseEventArgs>();
25462605
const onItemHoveredImpl = React.useCallback(
25472606
(args: GridMouseEventArgs) => {
@@ -2596,6 +2655,10 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
25962655
}
25972656

25982657
col = Math.max(col, rowMarkerOffset);
2658+
const clampLimits = getSelectionRowLimits(selectedRow);
2659+
row = clampLimits === undefined ? row : clamp(row, clampLimits[0], clampLimits[1]);
2660+
2661+
// FIXME: Restrict row based on rowGrouping.selectionBehavior here
25992662

26002663
const deltaX = col - selectedCol;
26012664
const deltaY = row - selectedRow;
@@ -2622,7 +2685,6 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
26222685
onItemHovered?.({ ...args, location: [args.location[0] - rowMarkerOffset, args.location[1]] as any });
26232686
},
26242687
[
2625-
allowedFillDirections,
26262688
mouseState,
26272689
rowMarkerOffset,
26282690
rowSelect,
@@ -2632,6 +2694,8 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
26322694
setSelectedRows,
26332695
showTrailingBlankRow,
26342696
rows,
2697+
allowedFillDirections,
2698+
getSelectionRowLimits,
26352699
setCurrent,
26362700
]
26372701
);
@@ -2676,20 +2740,23 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
26762740
let top = old.y;
26772741
let bottom = old.y + old.height;
26782742

2743+
const [minRow, maxRowRaw] = getSelectionRowLimits(row) ?? [0, rows - 1];
2744+
const maxRow = maxRowRaw + 1; // we need an inclusive value
2745+
26792746
// take care of vertical first in case new spans come in
26802747
if (y !== 0) {
26812748
switch (y) {
26822749
case 2: {
26832750
// go to end
2684-
bottom = rows;
2751+
bottom = maxRow;
26852752
top = row;
26862753
scrollTo(0, bottom, "vertical");
26872754

26882755
break;
26892756
}
26902757
case -2: {
26912758
// go to start
2692-
top = 0;
2759+
top = minRow;
26932760
bottom = row + 1;
26942761
scrollTo(0, top, "vertical");
26952762

@@ -2701,7 +2768,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
27012768
top++;
27022769
scrollTo(0, top, "vertical");
27032770
} else {
2704-
bottom = Math.min(rows, bottom + 1);
2771+
bottom = Math.min(maxRow, bottom + 1);
27052772
scrollTo(0, bottom, "vertical");
27062773
}
27072774

@@ -2713,7 +2780,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
27132780
bottom--;
27142781
scrollTo(0, bottom, "vertical");
27152782
} else {
2716-
top = Math.max(0, top - 1);
2783+
top = Math.max(minRow, top - 1);
27172784
scrollTo(0, top, "vertical");
27182785
}
27192786

@@ -2816,7 +2883,16 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
28162883
"keyboard-select"
28172884
);
28182885
},
2819-
[getCellsForSelection, gridSelection, mangledCols.length, rowMarkerOffset, rows, scrollTo, setCurrent]
2886+
[
2887+
getCellsForSelection,
2888+
getSelectionRowLimits,
2889+
gridSelection,
2890+
mangledCols.length,
2891+
rowMarkerOffset,
2892+
rows,
2893+
scrollTo,
2894+
setCurrent,
2895+
]
28202896
);
28212897

28222898
const updateSelectedCell = React.useCallback(
@@ -2825,7 +2901,10 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
28252901
col = clamp(col, rowMarkerOffset, columns.length - 1 + rowMarkerOffset);
28262902
row = clamp(row, 0, rowMax);
28272903

2828-
if (col === currentCell?.[0] && row === currentCell?.[1]) return false;
2904+
const curCol = currentCell?.[0];
2905+
const curRow = currentCell?.[1];
2906+
2907+
if (col === curCol && row === curRow) return false;
28292908
if (freeMove && gridSelection.current !== undefined) {
28302909
const newStack = [...gridSelection.current.rangeStack];
28312910
if (gridSelection.current.range.width > 1 || gridSelection.current.range.height > 1) {
@@ -3041,6 +3120,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
30413120

30423121
if (gridSelection.current === undefined) return false;
30433122
let [col, row] = gridSelection.current.cell;
3123+
const [, startRow] = gridSelection.current.cell;
30443124
let freeMove = false;
30453125
let cancelOnlyOnMove = false;
30463126

@@ -3174,6 +3254,37 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
31743254
}
31753255
// #endregion
31763256

3257+
const mustRestrictRow = rowGroupingNavBehavior !== undefined && rowGroupingNavBehavior !== "normal";
3258+
3259+
if (mustRestrictRow && row !== startRow) {
3260+
const skipUp =
3261+
rowGroupingNavBehavior === "skip-up" ||
3262+
rowGroupingNavBehavior === "skip" ||
3263+
rowGroupingNavBehavior === "block";
3264+
const skipDown =
3265+
rowGroupingNavBehavior === "skip-down" ||
3266+
rowGroupingNavBehavior === "skip" ||
3267+
rowGroupingNavBehavior === "block";
3268+
const didMoveUp = row < startRow;
3269+
if (didMoveUp && skipUp) {
3270+
while (row >= 0 && mapper(row).isGroupHeader) {
3271+
row--;
3272+
}
3273+
3274+
if (row < 0) {
3275+
row = startRow;
3276+
}
3277+
} else if (!didMoveUp && skipDown) {
3278+
while (row < rows && mapper(row).isGroupHeader) {
3279+
row++;
3280+
}
3281+
3282+
if (row >= rows) {
3283+
row = startRow;
3284+
}
3285+
}
3286+
}
3287+
31773288
const moved = updateSelectedCell(col, row, false, freeMove);
31783289

31793290
const didMatch = details.didMatch;
@@ -3185,13 +3296,15 @@ const DataEditorImpl: React.ForwardRefRenderFunction<DataEditorRef, DataEditorPr
31853296
return didMatch;
31863297
},
31873298
[
3299+
rowGroupingNavBehavior,
31883300
overlayOpen,
31893301
gridSelection,
31903302
keybindings,
31913303
columnSelect,
31923304
rowSelect,
31933305
rangeSelect,
31943306
rowMarkerOffset,
3307+
mapper,
31953308
rows,
31963309
updateSelectedCell,
31973310
setGridSelection,

0 commit comments

Comments
 (0)
Failed to load comments.