diff --git a/CHANGELOG.md b/CHANGELOG.md index 72db8f52a8..b5d40d6932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ - `rowClass` - `defaultColumnOptions` - ⚠️ This replaces the `minColumnWidth` and `defaultFormatter` props + - `groupBy` + - `rowGrouper` + - More info in [#2106](https://github.com/adazzle/react-data-grid/pull/2106) - `column.cellClass(row)` function support: - `column = { ..., cellClass(row) { return string; } }` - `column.minWidth` @@ -26,6 +29,8 @@ - `column.editor2` - `column.editorOptions` - More info in [#2102](https://github.com/adazzle/react-data-grid/pull/2102) + - `column.groupFormatter` + - More info in [#2106](https://github.com/adazzle/react-data-grid/pull/2106) - `scrollToRow` method - ⚠️ This replaces the `scrollToRowIndex` prop - **Removed:** diff --git a/package.json b/package.json index 210ac87f63..64a93ebec6 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "react-dom": "next", "react-popper": "^2.2.3", "react-select": "^3.1.0", + "react-sortable-hoc": "^1.11.0", "ts-jest": "^26.1.4", "ts-loader": "^8.0.2", "typescript": "~4.0.2" diff --git a/src/Cell.tsx b/src/Cell.tsx index beb11529cf..84d6cffa1b 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -79,16 +79,20 @@ function Cell({ onContextMenu={wrapEvent(handleContextMenu, onContextMenu)} {...props} > - - {dragHandleProps && ( -
+ {!column.rowGroup && ( + <> + + {dragHandleProps && ( +
+ )} + )}
); diff --git a/src/Columns.tsx b/src/Columns.tsx index f3d7e72b5b..1afb7599b4 100644 --- a/src/Columns.tsx +++ b/src/Columns.tsx @@ -3,10 +3,12 @@ import { SelectCellFormatter } from './formatters'; import { Column } from './types'; import { stopPropagation } from './utils'; +export const SELECT_COLUMN_KEY = 'select-row'; + // TODO: fix type // eslint-disable-next-line @typescript-eslint/no-explicit-any export const SelectColumn: Column = { - key: 'select-row', + key: SELECT_COLUMN_KEY, name: '', width: 35, maxWidth: 35, @@ -33,5 +35,18 @@ export const SelectColumn: Column = { onChange={props.onRowSelectionChange} /> ); + }, + groupFormatter(props) { + return ( + + ); } }; diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 5aee4646e3..4b170effea 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -10,16 +10,16 @@ import React, { } from 'react'; import clsx from 'clsx'; -import { useGridDimensions, useViewportColumns } from './hooks'; +import { useGridDimensions, useViewportColumns, useViewportRows } from './hooks'; import EventBus from './EventBus'; import HeaderRow from './HeaderRow'; import FilterRow from './FilterRow'; import Row from './Row'; +import GroupRowRenderer from './GroupRow'; import SummaryRow from './SummaryRow'; import { assertIsValidKey, getColumnScrollPosition, - getVerticalRangeToRender, getNextSelectedCellPosition, isSelectedCellEditable, canExitGrid, @@ -38,7 +38,8 @@ import { SelectRowEvent, CommitEvent, SelectedCellProps, - EditCellProps + EditCellProps, + Dictionary } from './types'; import { CellNavigationMode, SortDirection, UpdateActions } from './enums'; @@ -126,6 +127,10 @@ export interface DataGridProps extends Share filters?: Filters; onFiltersChange?: (filters: Filters) => void; defaultColumnOptions?: DefaultColumnOptions; + groupBy?: readonly string[]; + rowGrouper?: (rows: readonly R[], columnKey: string) => Dictionary; + expandedGroupIds?: ReadonlySet; + onExpandedGroupIdsChange?: (expandedGroupIds: Set) => void; /** * Custom renderers @@ -174,7 +179,7 @@ export interface DataGridProps extends Share function DataGrid({ // Grid and data Props columns: rawColumns, - rows, + rows: rawRows, summaryRows, rowKey, onRowsUpdate, @@ -192,6 +197,10 @@ function DataGrid({ filters, onFiltersChange, defaultColumnOptions, + groupBy: rawGroupBy, + rowGrouper, + expandedGroupIds, + onExpandedGroupIdsChange, // Custom renderers rowRenderer: RowRenderer = Row, emptyRowsRenderer, @@ -252,20 +261,33 @@ function DataGrid({ const clientHeight = gridHeight - totalHeaderHeight - summaryRowsCount * rowHeight; const isSelectable = selectedRows !== undefined && onSelectedRowsChange !== undefined; - const { columns, viewportColumns, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth } = useViewportColumns({ - columns: rawColumns, + const { columns, viewportColumns, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth, groupBy } = useViewportColumns({ + rawColumns, columnWidths, scrollLeft, viewportWidth: gridWidth, - defaultColumnOptions + defaultColumnOptions, + rawGroupBy, + rowGrouper }); - const [rowOverscanStartIdx, rowOverscanEndIdx] = getVerticalRangeToRender( - clientHeight, + const { rowOverscanStartIdx, rowOverscanEndIdx, rows, rowsCount, isGroupRow } = useViewportRows({ + rawRows, + groupBy, + rowGrouper, rowHeight, + clientHeight, scrollTop, - rows.length - ); + expandedGroupIds + }); + + const hasGroups = groupBy.length > 0 && rowGrouper; + const minColIdx = hasGroups ? -1 : 0; + + if (hasGroups) { + // Cell drag is not supported on a treegrid + enableCellDragAndDrop = false; + } /** * effects @@ -288,8 +310,20 @@ function DataGrid({ const handleRowSelectionChange = ({ rowIdx, checked, isShiftClick }: SelectRowEvent) => { assertIsValidKey(rowKey); const newSelectedRows = new Set(selectedRows); - const rowId = rows[rowIdx][rowKey]; + const row = rows[rowIdx]; + if (isGroupRow(row)) { + for (const childRow of row.childRows) { + if (checked) { + newSelectedRows.add(childRow[rowKey]); + } else { + newSelectedRows.delete(childRow[rowKey]); + } + } + onSelectedRowsChange(newSelectedRows); + return; + } + const rowId = row[rowKey]; if (checked) { newSelectedRows.add(rowId); const previousRowIdx = lastSelectedRowIdx.current; @@ -297,7 +331,9 @@ function DataGrid({ if (isShiftClick && previousRowIdx !== -1 && previousRowIdx !== rowIdx) { const step = Math.sign(rowIdx - previousRowIdx); for (let i = previousRowIdx + step; i !== rowIdx; i += step) { - newSelectedRows.add(rows[i][rowKey]); + const row = rows[i]; + if (isGroupRow(row)) continue; + newSelectedRows.add(row[rowKey]); } } } else { @@ -309,12 +345,28 @@ function DataGrid({ }; return eventBus.subscribe('SELECT_ROW', handleRowSelectionChange); - }, [eventBus, onSelectedRowsChange, rows, rowKey, selectedRows]); + }, [eventBus, isGroupRow, onSelectedRowsChange, rowKey, rows, selectedRows]); useEffect(() => { return eventBus.subscribe('SELECT_CELL', selectCell); }); + useEffect(() => { + if (!onExpandedGroupIdsChange) return; + + const toggleGroup = (expandedGroupId: unknown) => { + const newExpandedGroupIds = new Set(expandedGroupIds); + if (newExpandedGroupIds.has(expandedGroupId)) { + newExpandedGroupIds.delete(expandedGroupId); + } else { + newExpandedGroupIds.add(expandedGroupId); + } + onExpandedGroupIdsChange(newExpandedGroupIds); + }; + + return eventBus.subscribe('TOGGLE_GROUP', toggleGroup); + }, [eventBus, expandedGroupIds, onExpandedGroupIdsChange]); + useImperativeHandle(ref, () => ({ scrollToColumn(idx: number) { scrollToCell({ idx }); @@ -331,9 +383,18 @@ function DataGrid({ * event handlers */ function handleKeyDown(event: React.KeyboardEvent) { - if (enableCellCopyPaste && isCtrlKeyHeldDown(event) && isCellWithinBounds(selectedPosition)) { - // event.key may be uppercase `C` or `V` - const lowerCaseKey = event.key.toLowerCase(); + const { key } = event; + const row = rows[selectedPosition.rowIdx]; + + if ( + enableCellCopyPaste + && isCtrlKeyHeldDown(event) + && isCellWithinBounds(selectedPosition) + && !isGroupRow(row) + && selectedPosition.idx !== -1 + ) { + // key may be uppercase `C` or `V` + const lowerCaseKey = key.toLowerCase(); if (lowerCaseKey === 'c') { handleCopy(); return; @@ -344,6 +405,21 @@ function DataGrid({ } } + if ( + isCellWithinBounds(selectedPosition) + && isGroupRow(row) + && selectedPosition.idx === -1 + && ( + // Collapse the current group row if it is focused and is in expanded state + (key === 'ArrowLeft' && row.isExpanded) + // Expand the current group row if it is focused and is in collapsed state + || (key === 'ArrowRight' && !row.isExpanded) + )) { + event.preventDefault(); // Prevents scrolling + eventBus.dispatch('TOGGLE_GROUP', row.id); + return; + } + switch (event.key) { case 'Escape': setCopiedPosition(null); @@ -385,7 +461,12 @@ function DataGrid({ onColumnResize?.(column.idx, width); }, [columnWidths, onColumnResize]); + function getRawRowIdx(rowIdx: number) { + return hasGroups ? rawRows.indexOf(rows[rowIdx] as R) : rowIdx; + } + function handleCommit({ cellKey, rowIdx, updated }: CommitEvent) { + rowIdx = getRawRowIdx(rowIdx); onRowsUpdate?.({ cellKey, fromRow: rowIdx, @@ -405,14 +486,15 @@ function DataGrid({ return; } - const updatedRows = [...rows]; - updatedRows[selectedPosition.rowIdx] = selectedPosition.row; + const updatedRows = [...rawRows]; + updatedRows[getRawRowIdx(selectedPosition.rowIdx)] = selectedPosition.row; onRowsChange?.(updatedRows); } function handleCopy() { const { idx, rowIdx } = selectedPosition; - const value = rows[rowIdx][columns[idx].key as keyof R]; + const rawRowIdx = getRawRowIdx(rowIdx); + const value = rawRows[rawRowIdx][columns[idx].key as keyof R]; setCopiedPosition({ idx, rowIdx, value }); } @@ -425,17 +507,16 @@ function DataGrid({ return; } - const { rowIdx: toRow } = selectedPosition; - + const fromRow = getRawRowIdx(copiedPosition.rowIdx); + const fromCellKey = columns[copiedPosition.idx].key; + const toRow = getRawRowIdx(selectedPosition.rowIdx); const cellKey = columns[selectedPosition.idx].key; - const { rowIdx: fromRow, idx, value } = copiedPosition; - const fromCellKey = columns[idx].key; onRowsUpdate?.({ cellKey, fromRow, toRow, - updated: { [cellKey]: value } as unknown as never, + updated: { [cellKey]: copiedPosition.value } as unknown as never, action: UpdateActions.COPY_PASTE, fromCellKey }); @@ -443,6 +524,8 @@ function DataGrid({ function handleCellInput(event: React.KeyboardEvent) { if (!isCellWithinBounds(selectedPosition)) return; + const row = rows[selectedPosition.rowIdx]; + if (isGroupRow(row)) return; const { key } = event; const column = columns[selectedPosition.idx]; @@ -464,8 +547,8 @@ function DataGrid({ rowIdx, key, mode: 'EDIT', - row: rows[rowIdx], - originalRow: rows[rowIdx] + row, + originalRow: row })); } } @@ -476,7 +559,7 @@ function DataGrid({ const { idx, rowIdx } = selectedPosition; const column = columns[idx]; const cellKey = column.key; - const value = rows[rowIdx][cellKey as keyof R]; + const value = rawRows[rowIdx][cellKey as keyof R]; onRowsUpdate?.({ cellKey, @@ -515,12 +598,12 @@ function DataGrid({ const column = columns[selectedPosition.idx]; const cellKey = column.key; - const value = rows[selectedPosition.rowIdx][cellKey as keyof R]; + const value = rawRows[selectedPosition.rowIdx][cellKey as keyof R]; onRowsUpdate?.({ cellKey, fromRow: selectedPosition.rowIdx, - toRow: rows.length - 1, + toRow: rawRows.length - 1, updated: { [cellKey]: value } as unknown as never, action: UpdateActions.COLUMN_FILL }); @@ -529,8 +612,8 @@ function DataGrid({ function handleRowChange(row: Readonly, commitChanges?: boolean) { if (selectedPosition.mode === 'SELECT') return; if (commitChanges) { - const updatedRows = [...rows]; - updatedRows[selectedPosition.rowIdx] = row; + const updatedRows = [...rawRows]; + updatedRows[getRawRowIdx(selectedPosition.rowIdx)] = row; onRowsChange?.(updatedRows); closeEditor(); } else { @@ -549,12 +632,12 @@ function DataGrid({ * utils */ function isCellWithinBounds({ idx, rowIdx }: Position): boolean { - return rowIdx >= 0 && rowIdx < rows.length && idx >= 0 && idx < columns.length; + return rowIdx >= 0 && rowIdx < rows.length && idx >= minColIdx && idx < columns.length; } function isCellEditable(position: Position): boolean { return isCellWithinBounds(position) - && isSelectedCellEditable({ columns, rows, selectedPosition: position, onCheckCellIsEditable }); + && isSelectedCellEditable({ columns, rows, selectedPosition: position, onCheckCellIsEditable, isGroupRow }); } function selectCell(position: Position, enableEditor = false): void { @@ -562,7 +645,7 @@ function DataGrid({ commitEditor2Changes(); if (enableEditor && isCellEditable(position)) { - const row = rows[position.rowIdx]; + const row = rows[position.rowIdx] as R; setSelectedPosition({ ...position, mode: 'EDIT', key: null, row, originalRow: row }); } else { setSelectedPosition({ ...position, mode: 'SELECT' }); @@ -603,6 +686,30 @@ function DataGrid({ function getNextPosition(key: string, ctrlKey: boolean, shiftKey: boolean): Position { const { idx, rowIdx } = selectedPosition; + const row = rows[rowIdx]; + const isRowSelected = isCellWithinBounds(selectedPosition) && idx === -1; + + // If a group row is focused, and it is collapsed, move to the parent group row (if there is one). + if ( + key === 'ArrowLeft' + && isRowSelected + && isGroupRow(row) + && !row.isExpanded + && row.level !== 0 + ) { + let parentRowIdx = -1; + for (let i = selectedPosition.rowIdx - 1; i >= 0; i--) { + const parentRow = rows[i]; + if (isGroupRow(parentRow) && parentRow.id === row.parentId) { + parentRowIdx = i; + break; + } + } + if (parentRowIdx !== -1) { + return { idx, rowIdx: parentRowIdx }; + } + } + switch (key) { case 'ArrowUp': return { idx, rowIdx: rowIdx - 1 }; @@ -618,8 +725,12 @@ function DataGrid({ } return { idx: idx + (shiftKey ? -1 : 1), rowIdx }; case 'Home': + // If row is selected then move focus to the first row + if (isRowSelected) return { idx, rowIdx: 0 }; return ctrlKey ? { idx: 0, rowIdx: 0 } : { idx: 0, rowIdx }; case 'End': + // If row is selected then move focus to the last row. + if (isRowSelected) return { idx, rowIdx: rows.length - 1 }; return ctrlKey ? { idx: columns.length - 1, rowIdx: rows.length - 1 } : { idx: columns.length - 1, rowIdx }; case 'PageUp': return { idx, rowIdx: rowIdx - Math.floor(clientHeight / rowHeight) }; @@ -710,10 +821,39 @@ function DataGrid({ function getViewportRows() { const rowElements = []; - + let startRowIndex = 0; for (let rowIdx = rowOverscanStartIdx; rowIdx <= rowOverscanEndIdx; rowIdx++) { const row = rows[rowIdx]; - let key: string | number = rowIdx; + const top = rowIdx * rowHeight + totalHeaderHeight; + if (isGroupRow(row)) { + ({ startRowIndex } = row); + rowElements.push( + + aria-level={row.level + 1} // aria-level is 1-based + aria-setsize={row.setSize} + aria-posinset={row.posInSet + 1} // aria-posinset is 1-based + aria-rowindex={headerRowsCount + startRowIndex + 1} // aria-rowindex is 1 based + key={row.id} + id={row.id} + groupKey={row.groupKey} + viewportColumns={viewportColumns} + childRows={row.childRows} + rowIdx={rowIdx} + top={top} + level={row.level} + isExpanded={row.isExpanded} + selectedCellIdx={selectedPosition.rowIdx === rowIdx ? selectedPosition.idx : undefined} + isRowSelected={isSelectable && row.childRows.every(cr => selectedRows?.has(cr[rowKey!]))} + eventBus={eventBus} + onFocus={selectedPosition.rowIdx === rowIdx ? handleFocus : undefined} + onKeyDown={selectedPosition.rowIdx === rowIdx ? handleKeyDown : undefined} + /> + ); + continue; + } + + startRowIndex++; + let key: string | number = hasGroups ? startRowIndex : rowIdx; let isRowSelected = false; if (rowKey !== undefined) { const rowId = row[rowKey]; @@ -725,7 +865,7 @@ function DataGrid({ rowElements.push( ({ isRowSelected={isRowSelected} onRowClick={onRowClick} rowClass={rowClass} - top={rowIdx * rowHeight + totalHeaderHeight} + top={top} copiedCellIdx={copiedPosition?.rowIdx === rowIdx ? copiedPosition.idx : undefined} draggedOverCellIdx={getDraggedOverCellIdx(rowIdx)} setDraggedOverRowIdx={isDragging ? setDraggedOverRowIdx : undefined} @@ -761,13 +901,13 @@ function DataGrid({ return (
({ > rowKey={rowKey} - rows={rows} + rows={rawRows} columns={viewportColumns} onColumnResize={handleColumnResize} - allRowsSelected={selectedRows?.size === rows.length} + allRowsSelected={selectedRows?.size === rawRows.length} onSelectedRowsChange={onSelectedRowsChange} sortColumn={sortColumn} sortDirection={sortDirection} @@ -809,7 +949,7 @@ function DataGrid({ {getViewportRows()} {summaryRows?.map((row, rowIdx) => ( - aria-rowindex={headerRowsCount + rows.length + rowIdx + 1} + aria-rowindex={headerRowsCount + rowsCount + rowIdx + 1} key={rowIdx} rowIdx={rowIdx} row={row} diff --git a/src/EventBus.ts b/src/EventBus.ts index 7acbda57e5..e8fe095733 100644 --- a/src/EventBus.ts +++ b/src/EventBus.ts @@ -3,6 +3,7 @@ import { Position, SelectRowEvent } from './types'; interface EventMap { SELECT_CELL: (position: Position, openEditor?: boolean) => void; SELECT_ROW: (event: SelectRowEvent) => void; + TOGGLE_GROUP: (id: unknown) => void; } type EventName = keyof EventMap; diff --git a/src/GroupCell.tsx b/src/GroupCell.tsx new file mode 100644 index 0000000000..a373d27ed5 --- /dev/null +++ b/src/GroupCell.tsx @@ -0,0 +1,77 @@ +import React, { memo } from 'react'; +import clsx from 'clsx'; +import { GroupRowRendererProps, CalculatedColumn } from './types'; + +type SharedGroupRowRendererProps = Pick, + | 'id' + | 'rowIdx' + | 'groupKey' + | 'childRows' + | 'isExpanded' + | 'isRowSelected' + | 'eventBus' +>; + +interface GroupCellProps extends SharedGroupRowRendererProps { + column: CalculatedColumn; + isCellSelected: boolean; + groupColumnIndex: number; +} + +function GroupCell({ + id, + rowIdx, + groupKey, + childRows, + isExpanded, + isCellSelected, + isRowSelected, + eventBus, + column, + groupColumnIndex +}: GroupCellProps) { + function toggleGroup() { + eventBus.dispatch('TOGGLE_GROUP', id); + } + + function onRowSelectionChange(checked: boolean) { + eventBus.dispatch('SELECT_ROW', { rowIdx, checked, isShiftClick: false }); + } + + // Only make the cell clickable if the group level matches + const isLevelMatching = column.rowGroup && groupColumnIndex === column.idx; + + return ( +
+ {column.groupFormatter && (!column.rowGroup || groupColumnIndex === column.idx) && ( + + )} +
+ ); +} + +export default memo(GroupCell) as (props: GroupCellProps) => JSX.Element; diff --git a/src/GroupRow.tsx b/src/GroupRow.tsx new file mode 100644 index 0000000000..1ccd21cd50 --- /dev/null +++ b/src/GroupRow.tsx @@ -0,0 +1,62 @@ +import React, { memo } from 'react'; +import clsx from 'clsx'; +import { GroupRowRendererProps } from './types'; +import { SELECT_COLUMN_KEY } from './Columns'; +import GroupCell from './GroupCell'; + +function GroupedRow({ + id, + groupKey, + viewportColumns, + childRows, + rowIdx, + top, + level, + isExpanded, + selectedCellIdx, + isRowSelected, + eventBus, + ...props +}: GroupRowRendererProps) { + // Select is always the first column + const idx = viewportColumns[0].key === SELECT_COLUMN_KEY ? level + 1 : level; + + function selectGroup() { + eventBus.dispatch('SELECT_CELL', { rowIdx, idx: -1 }); + } + + return ( +
+ {viewportColumns.map(column => ( + + key={column.key} + id={id} + rowIdx={rowIdx} + groupKey={groupKey} + childRows={childRows} + isExpanded={isExpanded} + isRowSelected={isRowSelected} + isCellSelected={selectedCellIdx === column.idx} + eventBus={eventBus} + column={column} + groupColumnIndex={idx} + /> + ))} +
+ ); +} + +export default memo(GroupedRow) as (props: GroupRowRendererProps) => JSX.Element; diff --git a/src/Row.tsx b/src/Row.tsx index 6b7a750802..b301055ba0 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -32,8 +32,10 @@ function Row({ className = clsx( 'rdg-row', - `rdg-row-${rowIdx % 2 === 0 ? 'even' : 'odd'}`, - { 'rdg-row-selected': isRowSelected }, + `rdg-row-${rowIdx % 2 === 0 ? 'even' : 'odd'}`, { + 'rdg-row-selected': isRowSelected, + 'rdg-group-row-selected': selectedCellProps?.idx === -1 + }, rowClass?.(row), className ); diff --git a/src/formatters/SelectCellFormatter.tsx b/src/formatters/SelectCellFormatter.tsx index 585cc72ad6..d1185d2d31 100644 --- a/src/formatters/SelectCellFormatter.tsx +++ b/src/formatters/SelectCellFormatter.tsx @@ -6,9 +6,9 @@ import { useFocusRef } from '../hooks'; type SharedInputProps = Pick, | 'disabled' | 'tabIndex' + | 'onClick' | 'aria-label' | 'aria-labelledby' - | 'onClick' >; export interface SelectCellFormatterProps extends SharedInputProps { @@ -43,9 +43,9 @@ export function SelectCellFormatter({ type="checkbox" className="rdg-checkbox-input" disabled={disabled} + checked={value} onChange={handleChange} onClick={onClick} - checked={value} />
diff --git a/src/formatters/ToggleGroupFormatter.tsx b/src/formatters/ToggleGroupFormatter.tsx new file mode 100644 index 0000000000..5a561d0432 --- /dev/null +++ b/src/formatters/ToggleGroupFormatter.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { GroupFormatterProps } from '../types'; +import { useFocusRef } from '../hooks'; + + +export function ToggleGroupFormatter({ + groupKey, + isExpanded, + isCellSelected, + toggleGroup +}: GroupFormatterProps) { + const cellRef = useFocusRef(isCellSelected); + + function handleKeyDown({ key }: React.KeyboardEvent) { + if (key === 'Enter') { + toggleGroup(); + } + } + + return ( + <> + {groupKey} + + + ); +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts index 998b261e06..e4af276e17 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -1,3 +1,4 @@ export * from './SelectCellFormatter'; export * from './SimpleCellFormatter'; export * from './ValueFormatter'; +export * from './ToggleGroupFormatter'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4daf432e9f..7df838a224 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,4 +2,5 @@ export * from './useCombinedRefs'; export * from './useClickOutside'; export * from './useGridDimensions'; export * from './useViewportColumns'; +export * from './useViewportRows'; export * from './useFocusRef'; diff --git a/src/hooks/useViewportColumns.ts b/src/hooks/useViewportColumns.ts index 379f054cc8..2fc34bd18f 100644 --- a/src/hooks/useViewportColumns.ts +++ b/src/hooks/useViewportColumns.ts @@ -1,41 +1,49 @@ import { useMemo } from 'react'; -import { CalculatedColumn } from '../types'; +import { CalculatedColumn, Column } from '../types'; import { getColumnMetrics } from '../utils'; import { DataGridProps } from '../DataGrid'; import { ValueFormatter } from '../formatters'; -type SharedDataGridProps = Pick, 'columns' | 'defaultColumnOptions'>; +type SharedDataGridProps = Pick, + | 'defaultColumnOptions' + | 'rowGrouper' +>; interface ViewportColumnsArgs extends SharedDataGridProps { + rawColumns: readonly Column[]; + rawGroupBy?: readonly string[]; viewportWidth: number; scrollLeft: number; columnWidths: ReadonlyMap; } export function useViewportColumns({ - columns: rawColumns, + rawColumns, columnWidths, viewportWidth, scrollLeft, - defaultColumnOptions + defaultColumnOptions, + rawGroupBy, + rowGrouper }: ViewportColumnsArgs) { const minColumnWidth = defaultColumnOptions?.minWidth ?? 80; const defaultFormatter = defaultColumnOptions?.formatter ?? ValueFormatter; const defaultSortable = defaultColumnOptions?.sortable ?? false; const defaultResizable = defaultColumnOptions?.resizable ?? false; - const { columns, lastFrozenColumnIndex, totalColumnWidth, totalFrozenColumnWidth } = useMemo(() => { + const { columns, lastFrozenColumnIndex, totalColumnWidth, totalFrozenColumnWidth, groupBy } = useMemo(() => { return getColumnMetrics({ - columns: rawColumns, + rawColumns, minColumnWidth, viewportWidth, columnWidths, defaultSortable, defaultResizable, - defaultFormatter + defaultFormatter, + rawGroupBy: rowGrouper ? rawGroupBy : undefined }); - }, [columnWidths, defaultFormatter, defaultResizable, defaultSortable, minColumnWidth, rawColumns, viewportWidth]); + }, [columnWidths, defaultFormatter, defaultResizable, defaultSortable, minColumnWidth, rawColumns, rawGroupBy, rowGrouper, viewportWidth]); const [colOverscanStartIdx, colOverscanEndIdx] = useMemo((): [number, number] => { // get the viewport's left side and right side positions for non-frozen columns @@ -92,5 +100,5 @@ export function useViewportColumns({ return viewportColumns; }, [colOverscanEndIdx, colOverscanStartIdx, columns]); - return { columns, viewportColumns, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth }; + return { columns, viewportColumns, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth, groupBy }; } diff --git a/src/hooks/useViewportRows.ts b/src/hooks/useViewportRows.ts new file mode 100644 index 0000000000..97eee1366e --- /dev/null +++ b/src/hooks/useViewportRows.ts @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; + +import { GroupRow, GroupByDictionary, Dictionary } from '../types'; +const RENDER_BACTCH_SIZE = 8; + +interface ViewportRowsArgs { + rawRows: readonly R[]; + rowHeight: number; + clientHeight: number; + scrollTop: number; + groupBy: readonly string[]; + rowGrouper?: (rows: readonly R[], columnKey: string) => Dictionary; + expandedGroupIds?: ReadonlySet; +} + +export function useViewportRows({ + rawRows, + rowHeight, + clientHeight, + scrollTop, + groupBy, + rowGrouper, + expandedGroupIds +}: ViewportRowsArgs) { + const [groupedRows, rowsCount] = useMemo(() => { + if (groupBy.length === 0 || !rowGrouper) return [undefined, rawRows.length]; + + const groupRows = (rows: readonly R[], [groupByKey, ...remainingGroupByKeys]: readonly string[], startRowIndex: number): [GroupByDictionary, number] => { + let groupRowsCount = 0; + const groups: GroupByDictionary = {}; + for (const [key, childRows] of Object.entries(rowGrouper(rows, groupByKey))) { + // Recursively group each parent group + const [childGroups, childRowsCount] = remainingGroupByKeys.length === 0 + ? [childRows, childRows.length] + : groupRows(childRows, remainingGroupByKeys, startRowIndex + groupRowsCount + 1); // 1 for parent row + groups[key] = { childRows, childGroups, startRowIndex: startRowIndex + groupRowsCount }; + groupRowsCount += childRowsCount + 1; // 1 for parent row + } + + return [groups, groupRowsCount]; + }; + + return groupRows(rawRows, groupBy, 0); + }, [groupBy, rowGrouper, rawRows]); + + const [rows, allGroupRows] = useMemo(() => { + const allGroupRows = new Set(); + if (!groupedRows) return [rawRows, allGroupRows]; + + const flattenedRows: Array> = []; + const expandGroup = (rows: GroupByDictionary | readonly R[], parentId: string | undefined, level: number): void => { + if (Array.isArray(rows)) { + flattenedRows.push(...rows); + return; + } + Object.keys(rows).forEach((groupKey, posInSet, keys) => { + // TODO: should users have control over the generated key? + const id = parentId !== undefined ? `${parentId}__${groupKey}` : groupKey; + const isExpanded = expandedGroupIds?.has(id) ?? false; + const { childRows, childGroups, startRowIndex } = (rows as GroupByDictionary)[groupKey]; // https://github.com/microsoft/TypeScript/issues/17002 + + const groupRow: GroupRow = { + id, + parentId, + groupKey, + isExpanded, + childRows, + level, + posInSet, + startRowIndex, + setSize: keys.length + }; + flattenedRows.push(groupRow); + allGroupRows.add(groupRow); + + if (isExpanded) { + expandGroup(childGroups, id, level + 1); + } + }); + }; + + expandGroup(groupedRows, undefined, 0); + return [flattenedRows, allGroupRows]; + }, [expandedGroupIds, groupedRows, rawRows]); + + const isGroupRow = (row: unknown): row is GroupRow => allGroupRows.has(row); + + const overscanThreshold = 4; + const rowVisibleStartIdx = Math.floor(scrollTop / rowHeight); + const rowVisibleEndIdx = Math.min(rows.length - 1, Math.floor((scrollTop + clientHeight) / rowHeight)); + const rowOverscanStartIdx = Math.max(0, Math.floor((rowVisibleStartIdx - overscanThreshold) / RENDER_BACTCH_SIZE) * RENDER_BACTCH_SIZE); + const rowOverscanEndIdx = Math.min(rows.length - 1, Math.ceil((rowVisibleEndIdx + overscanThreshold) / RENDER_BACTCH_SIZE) * RENDER_BACTCH_SIZE); + + return { + rowOverscanStartIdx, + rowOverscanEndIdx, + rows, + rowsCount, + isGroupRow + }; +} diff --git a/src/types.ts b/src/types.ts index 1979e20b45..fc993f5f99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export interface Column { formatter?: React.ComponentType>; /** Formatter to be used to render the summary cell content */ summaryFormatter?: React.ComponentType>; + /** Formatter to be used to render the group cell content */ + groupFormatter?: React.ComponentType>; /** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */ editable?: boolean | ((row: TRow) => boolean); /** Determines whether column is frozen or not */ @@ -61,6 +63,7 @@ export interface CalculatedColumn extends Column>; } @@ -92,6 +95,17 @@ export interface SummaryFormatterProps { row: TSummaryRow; } +export interface GroupFormatterProps { + groupKey: unknown; + column: CalculatedColumn; + childRows: readonly TRow[]; + isExpanded: boolean; + isCellSelected: boolean; + isRowSelected: boolean; + onRowSelectionChange: (checked: boolean) => void; + toggleGroup: () => void; +} + export interface EditorProps { ref: React.Ref>; column: CalculatedColumn; @@ -180,6 +194,20 @@ export interface RowRendererProps extends Omit void; } +export interface GroupRowRendererProps extends Omit, 'style' | 'children'> { + id: string; + groupKey: unknown; + viewportColumns: readonly CalculatedColumn[]; + childRows: readonly TRow[]; + rowIdx: number; + top: number; + level: number; + selectedCellIdx?: number; + isExpanded: boolean; + isRowSelected: boolean; + eventBus: EventBus; +} + export interface FilterRendererProps { column: CalculatedColumn; value: TFilterValue; @@ -213,3 +241,23 @@ export interface SelectRowEvent { checked: boolean; isShiftClick: boolean; } + +export type Dictionary = Record; + +export type GroupByDictionary = Dictionary<{ + childRows: readonly TRow[]; + childGroups: readonly TRow[] | GroupByDictionary; + startRowIndex: number; +}>; + +export interface GroupRow { + childRows: readonly TRow[]; + id: string; + parentId: unknown; + groupKey: unknown; + isExpanded: boolean; + level: number; + posInSet: number; + setSize: number; + startRowIndex: number; +} diff --git a/src/utils/columnUtils.test.ts b/src/utils/columnUtils.test.ts index 845a7caaf1..a285755bd0 100644 --- a/src/utils/columnUtils.test.ts +++ b/src/utils/columnUtils.test.ts @@ -27,9 +27,9 @@ describe('getColumnMetrics', () => { }]; it('should set the unset column widths based on the total width', () => { - const columns = getInitialColumns(); + const rawColumns = getInitialColumns(); const metrics = getColumnMetrics({ - columns, + rawColumns, viewportWidth, minColumnWidth: 50, columnWidths: new Map(), @@ -44,9 +44,9 @@ describe('getColumnMetrics', () => { }); it('should set the column left based on the column widths', () => { - const columns = getInitialColumns(); + const rawColumns = getInitialColumns(); const metrics = getColumnMetrics({ - columns, + rawColumns, viewportWidth, minColumnWidth: 50, columnWidths: new Map(), @@ -56,7 +56,7 @@ describe('getColumnMetrics', () => { }); expect(metrics.columns[0].left).toStrictEqual(0); - expect(metrics.columns[1].left).toStrictEqual(columns[0].width); + expect(metrics.columns[1].left).toStrictEqual(rawColumns[0].width); expect(metrics.columns[2].left).toStrictEqual(180); }); @@ -64,10 +64,10 @@ describe('getColumnMetrics', () => { const firstFrozenColumn: Column = { key: 'frozenColumn1', name: 'frozenColumn1', frozen: true }; const secondFrozenColumn: Column = { key: 'frozenColumn2', name: 'frozenColumn2', frozen: true }; const thirdFrozenColumn: Column = { key: 'frozenColumn3', name: 'frozenColumn3', frozen: true }; - const columns = [...getInitialColumns(), secondFrozenColumn, thirdFrozenColumn]; - columns.splice(2, 0, firstFrozenColumn); + const rawColumns = [...getInitialColumns(), secondFrozenColumn, thirdFrozenColumn]; + rawColumns.splice(2, 0, firstFrozenColumn); const metrics = getColumnMetrics({ - columns, + rawColumns, viewportWidth, minColumnWidth: 50, columnWidths: new Map(), diff --git a/src/utils/columnUtils.ts b/src/utils/columnUtils.ts index e68f02e425..fa016e4ded 100644 --- a/src/utils/columnUtils.ts +++ b/src/utils/columnUtils.ts @@ -1,13 +1,16 @@ -import { Column, CalculatedColumn, FormatterProps, Omit } from '../types'; +import { Column, CalculatedColumn, FormatterProps } from '../types'; +import { ToggleGroupFormatter } from '../formatters'; +import { SELECT_COLUMN_KEY } from '../Columns'; interface Metrics { - columns: readonly Column[]; + rawColumns: readonly Column[]; columnWidths: ReadonlyMap; minColumnWidth: number; viewportWidth: number; defaultResizable: boolean; defaultSortable: boolean; defaultFormatter: React.ComponentType>; + rawGroupBy?: readonly string[]; } interface ColumnMetrics { @@ -15,6 +18,7 @@ interface ColumnMetrics { lastFrozenColumnIndex: number; totalFrozenColumnWidth: number; totalColumnWidth: number; + groupBy: readonly string[]; } export function getColumnMetrics(metrics: Metrics): ColumnMetrics { @@ -23,10 +27,11 @@ export function getColumnMetrics(metrics: Metrics): ColumnMetrics< let allocatedWidths = 0; let unassignedColumnsCount = 0; let lastFrozenColumnIndex = -1; + type IntermediateColumn = Column & { width: number | undefined; rowGroup?: boolean }; let totalFrozenColumnWidth = 0; - const columns: Array, 'width'> & { width: number | undefined }> = []; + const { rawGroupBy } = metrics; - for (const metricsColumn of metrics.columns) { + const columns = metrics.rawColumns.map(metricsColumn => { let width = getSpecifiedWidth(metricsColumn, metrics.columnWidths, metrics.viewportWidth); if (width === undefined) { @@ -36,15 +41,44 @@ export function getColumnMetrics(metrics: Metrics): ColumnMetrics< allocatedWidths += width; } - const column = { ...metricsColumn, width }; + const column: IntermediateColumn = { ...metricsColumn, width }; + + if (rawGroupBy?.includes(column.key)) { + column.frozen = true; + column.rowGroup = true; + } if (column.frozen) { lastFrozenColumnIndex++; - columns.splice(lastFrozenColumnIndex, 0, column); - } else { - columns.push(column); } - } + + return column; + }); + + columns.sort(({ key: aKey, frozen: frozenA }, { key: bKey, frozen: frozenB }) => { + // Sort select column first: + if (aKey === SELECT_COLUMN_KEY) return -1; + if (bKey === SELECT_COLUMN_KEY) return 1; + + // Sort grouped columns second, following the groupBy order: + if (rawGroupBy?.includes(aKey)) { + if (rawGroupBy.includes(bKey)) { + return rawGroupBy.indexOf(aKey) - rawGroupBy.indexOf(bKey); + } + return -1; + } + if (rawGroupBy?.includes(bKey)) return 1; + + // Sort frozen columns third: + if (frozenA) { + if (frozenB) return 0; + return -1; + } + if (frozenB) return 1; + + // Sort other columns last: + return 0; + }); const unallocatedWidth = metrics.viewportWidth - allocatedWidths; const unallocatedColumnWidth = Math.max( @@ -52,6 +86,8 @@ export function getColumnMetrics(metrics: Metrics): ColumnMetrics< metrics.minColumnWidth ); + // Filter rawGroupBy and ignore keys that do not match the columns prop + const groupBy: string[] = []; const calculatedColumns: CalculatedColumn[] = columns.map((column, idx) => { // Every column should have a valid width as this stage const width = column.width ?? clampColumnWidth(unallocatedColumnWidth, column, metrics.minColumnWidth); @@ -64,6 +100,12 @@ export function getColumnMetrics(metrics: Metrics): ColumnMetrics< resizable: column.resizable ?? metrics.defaultResizable, formatter: column.formatter ?? metrics.defaultFormatter }; + + if (newColumn.rowGroup) { + groupBy.push(column.key); + newColumn.groupFormatter = column.groupFormatter ?? ToggleGroupFormatter; + } + totalWidth += width; left += width; return newColumn; @@ -79,7 +121,8 @@ export function getColumnMetrics(metrics: Metrics): ColumnMetrics< columns: calculatedColumns, lastFrozenColumnIndex, totalFrozenColumnWidth, - totalColumnWidth: totalWidth + totalColumnWidth: totalWidth, + groupBy }; } @@ -117,7 +160,7 @@ function clampColumnWidth( // Logic extented to allow for functions to be passed down in column.editable // this allows us to decide whether we can be editing from a cell level -export function canEdit(column: CalculatedColumn, row: R): boolean { +export function canEdit(column: Column, row: R): boolean { if (typeof column.editable === 'function') { return column.editable(row); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 07f4f17083..9f59b7076f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,5 @@ export * from './domUtils'; export * from './columnUtils'; -export * from './viewportUtils'; export * from './keyboardUtils'; export * from './selectedCellUtils'; diff --git a/src/utils/selectedCellUtils.ts b/src/utils/selectedCellUtils.ts index c16833ca64..3a900fe8a2 100644 --- a/src/utils/selectedCellUtils.ts +++ b/src/utils/selectedCellUtils.ts @@ -1,17 +1,19 @@ import { CellNavigationMode } from '../enums'; import { canEdit } from './columnUtils'; -import { CalculatedColumn, Position } from '../types'; +import { CalculatedColumn, Position, GroupRow } from '../types'; interface IsSelectedCellEditableOpts { selectedPosition: Position; columns: readonly CalculatedColumn[]; - rows: readonly R[]; + rows: readonly (R | GroupRow)[]; onCheckCellIsEditable?: (arg: { row: R; column: CalculatedColumn } & Position) => boolean; + isGroupRow: (row: R | GroupRow) => row is GroupRow; } -export function isSelectedCellEditable({ selectedPosition, columns, rows, onCheckCellIsEditable }: IsSelectedCellEditableOpts): boolean { +export function isSelectedCellEditable({ selectedPosition, columns, rows, onCheckCellIsEditable, isGroupRow }: IsSelectedCellEditableOpts): boolean { const column = columns[selectedPosition.idx]; const row = rows[selectedPosition.rowIdx]; + if (column.rowGroup || isGroupRow(row)) return false; const isCellEditable = onCheckCellIsEditable ? onCheckCellIsEditable({ row, column, ...selectedPosition }) : true; return isCellEditable && canEdit(column, row); } diff --git a/src/utils/viewportUtils.test.ts b/src/utils/viewportUtils.test.ts deleted file mode 100644 index a537d28e38..0000000000 --- a/src/utils/viewportUtils.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable jest/no-commented-out-tests */ -import { getVerticalRangeToRender } from './viewportUtils'; - -// interface Row { -// [key: string]: React.ReactNode; -// } - -interface VerticalRangeToRenderParams { - height: number; - rowHeight: number; - scrollTop: number; - rowsCount: number; -} - -describe('getVerticalRangeToRender', () => { - function getRange({ - height = 500, - rowHeight = 50, - scrollTop = 200, - rowsCount = 1000 - }: Partial) { - return getVerticalRangeToRender(height, rowHeight, scrollTop, rowsCount); - } - - it('should use rowHeight to calculate the range', () => { - expect(getRange({ rowHeight: 50 })).toStrictEqual([0, 24]); - }); - - it('should use height to calculate the range', () => { - expect(getRange({ height: 250 })).toStrictEqual([0, 16]); - }); - - it('should use scrollTop to calculate the range', () => { - expect(getRange({ scrollTop: 500 })).toStrictEqual([0, 24]); - }); - - it('should use rowsCount to calculate the range', () => { - expect(getRange({ rowsCount: 5, scrollTop: 0 })).toStrictEqual([0, 4]); - }); -}); - -// describe('getHorizontalRangeToRender', () => { -// function getColumns(): CalculatedColumn[] { -// return [...Array(500).keys()].map(i => ({ -// idx: i, -// key: `col${i}`, -// name: `col${i}`, -// width: 100, -// left: i * 100, -// resizable: false, -// sortable: false, -// formatter: ValueFormatter -// })); -// } - -// it('should use scrollLeft to calculate the range', () => { -// expect(getHorizontalRangeToRender( -// getColumns(), -// -1, -// 1000, -// 300 -// )).toStrictEqual([2, 13]); -// }); - -// it('should account for large columns', () => { -// const columns = getColumns(); -// columns[0].width = 500; -// columns.forEach((c, i) => { -// if (i !== 0) c.left += 400; -// }); -// expect(getHorizontalRangeToRender( -// columns, -// -1, -// 1000, -// 400 -// )).toStrictEqual([0, 10]); -// }); - -// it('should use viewportWidth to calculate the range', () => { -// const columns = getColumns(); -// expect(getHorizontalRangeToRender( -// columns, -// -1, -// 500, -// 200 -// )).toStrictEqual([1, 7]); -// }); - -// it('should use frozen columns to calculate the range', () => { -// const columns = getColumns(); -// columns[0].frozen = true; -// columns[1].frozen = true; -// columns[2].frozen = true; - -// expect(getHorizontalRangeToRender( -// columns, -// 2, -// 1000, -// 500 -// )).toStrictEqual([7, 15]); -// }); -// }); diff --git a/src/utils/viewportUtils.ts b/src/utils/viewportUtils.ts deleted file mode 100644 index 3990eca644..0000000000 --- a/src/utils/viewportUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -const RENDER_BACTCH_SIZE = 8; - -export function getVerticalRangeToRender( - height: number, - rowHeight: number, - scrollTop: number, - rowsCount: number -) { - const overscanThreshold = 4; - const rowVisibleStartIdx = Math.floor(scrollTop / rowHeight); - const rowVisibleEndIdx = Math.min(rowsCount - 1, Math.floor((scrollTop + height) / rowHeight)); - const rowOverscanStartIdx = Math.max(0, Math.floor((rowVisibleStartIdx - overscanThreshold) / RENDER_BACTCH_SIZE) * RENDER_BACTCH_SIZE); - const rowOverscanEndIdx = Math.min(rowsCount - 1, Math.ceil((rowVisibleEndIdx + overscanThreshold) / RENDER_BACTCH_SIZE) * RENDER_BACTCH_SIZE); - - return [rowOverscanStartIdx, rowOverscanEndIdx] as const; -} diff --git a/stories/demos/Grouping.less b/stories/demos/Grouping.less new file mode 100644 index 0000000000..e32592cc3b --- /dev/null +++ b/stories/demos/Grouping.less @@ -0,0 +1,10 @@ +.grouping-example { + display: flex; + flex-direction: column; + height: 100%; + gap: 10px; + + > .rdg { + flex: 1; + } +} diff --git a/stories/demos/Grouping.tsx b/stories/demos/Grouping.tsx new file mode 100644 index 0000000000..6b816afa82 --- /dev/null +++ b/stories/demos/Grouping.tsx @@ -0,0 +1,170 @@ +import React, { useState, useMemo } from 'react'; +import { groupBy as rowGrouper } from 'lodash'; +import Select, { components, ValueType, OptionsType, Props as SelectProps } from 'react-select'; +import { SortableContainer, SortableElement } from 'react-sortable-hoc'; +import faker from 'faker'; + +import DataGrid, { Column, Row, SelectColumn } from '../../src'; +import './Grouping.less'; + +interface Row { + id: number; + country: string; + year: number; + sport: string; + athlete: string; + gold: number; + silver: number; + bronze: number; +} + +interface Option { + value: string; + label: string; +} + +const sports = ['Swimming', 'Gymnastics', 'Speed Skating', 'Cross Country Skiing', 'Short-Track Speed Skating', 'Diving', 'Cycling', 'Biathlon', 'Alpine Skiing', 'Ski Jumping', 'Nordic Combined', 'Athletics', 'Table Tennis', 'Tennis', 'Synchronized Swimming', 'Shooting', 'Rowing', 'Fencing', 'Equestrian', 'Canoeing', 'Bobsleigh', 'Badminton', 'Archery', 'Wrestling', 'Weightlifting', 'Waterpolo', 'Wrestling', 'Weightlifting']; + +const columns: readonly Column[] = [ + SelectColumn, + { + key: 'country', + name: 'Country' + }, + { + key: 'year', + name: 'Year' + }, + { + key: 'sport', + name: 'Sport' + }, + { + key: 'athlete', + name: 'Athlete' + }, + { + key: 'gold', + name: 'Gold', + groupFormatter({ childRows }) { + return <>{childRows.reduce((prev, { gold }) => prev + gold, 0)}; + } + }, + { + key: 'silver', + name: 'Silver', + groupFormatter({ childRows }) { + return <>{childRows.reduce((prev, { silver }) => prev + silver, 0)}; + } + }, + { + key: 'bronze', + name: 'Bronze', + groupFormatter({ childRows }) { + return <>{childRows.reduce((prev, { silver }) => prev + silver, 0)}; + } + }, + { + key: 'total', + name: 'Total', + formatter({ row }) { + return <>{row.gold + row.silver + row.bronze}; + }, + groupFormatter({ childRows }) { + return <>{childRows.reduce((prev, row) => prev + row.gold + row.silver + row.bronze, 0)}; + } + } +]; + +function createRows(): readonly Row[] { + const rows: Row[] = []; + for (let i = 1; i < 10000; i++) { + rows.push({ + id: i, + year: 2015 + faker.random.number(3), + country: faker.address.country(), + sport: sports[faker.random.number(sports.length - 1)], + athlete: faker.name.findName(), + gold: faker.random.number(5), + silver: faker.random.number(5), + bronze: faker.random.number(5) + }); + } + + return rows.sort((r1, r2) => r2.country.localeCompare(r1.country)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SortableMultiValue = SortableElement((props: any) => { + const onMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + const innerProps = { onMouseDown }; + return ; +}); + +const SortableSelect = SortableContainer>(Select); + +const options: OptionsType