Skip to content

Commit

Permalink
perf(Table): improve selection handling and limit renders
Browse files Browse the repository at this point in the history
  • Loading branch information
pylafleur committed Dec 18, 2024
1 parent f47d1d8 commit 6e96803
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 139 deletions.
29 changes: 4 additions & 25 deletions packages/react/src/components/table/table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,27 +213,6 @@ describe('Table', () => {
expect(callback).toHaveBeenCalledTimes(1);
});

test('onSelectedRows callbacks are called on first render', () => {
const onSelectedRowIdsChange = jest.fn();
const onSelectedRowsChange = jest.fn();

mountWithTheme(
<Table<TestData>
rowSelectionMode="multiple"
columns={columns}
data={data}
rowIdField="id"
onSelectedRowIdsChange={onSelectedRowIdsChange}
onSelectedRowsChange={onSelectedRowsChange}
/>,
);

expect(onSelectedRowIdsChange).toHaveBeenCalledTimes(1);
expect(onSelectedRowIdsChange).toHaveBeenCalledWith([]);
expect(onSelectedRowsChange).toHaveBeenCalledTimes(1);
expect(onSelectedRowsChange).toHaveBeenCalledWith([]);
});

test('onSelectedRows callbacks are called when row-checkbox is checked', () => {
const onSelectedRowIdsChange = jest.fn();
const onSelectedRowsChange = jest.fn();
Expand All @@ -250,8 +229,8 @@ describe('Table', () => {

getByTestId(wrapper, 'row-checkbox-0').find('input').simulate('change', { target: { checked: true } });

expect(onSelectedRowIdsChange).nthCalledWith(2, [data[0].id]);
expect(onSelectedRowsChange).nthCalledWith(2, [data[0]]);
expect(onSelectedRowIdsChange).toHaveBeenCalledWith([data[0].id]);
expect(onSelectedRowsChange).toHaveBeenCalledWith([data[0]]);
});

test('onSelectedRows callbacks are called with all rows when row-checkbox-all is checked', () => {
Expand All @@ -270,8 +249,8 @@ describe('Table', () => {

getByTestId(wrapper, 'row-checkbox-all').find('input').simulate('change', { target: { checked: true } });

expect(onSelectedRowIdsChange).nthCalledWith(2, data.map((row) => row.id));
expect(onSelectedRowsChange).nthCalledWith(2, data);
expect(onSelectedRowIdsChange).toHaveBeenCalledWith(data.map((row) => row.id));
expect(onSelectedRowsChange).toHaveBeenCalledWith(data);
});

test('has desktop styles', () => {
Expand Down
132 changes: 80 additions & 52 deletions packages/react/src/components/table/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
useReactTable,
} from '@tanstack/react-table';
import { TFunction } from 'i18next';
import { Fragment, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { ChangeEvent, Fragment, ReactElement, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../../i18n/use-translation';
import { devConsole } from '../../utils/dev-console';
Expand All @@ -26,7 +26,7 @@ import { TableFooter } from './table-footer';
import { TableHeader } from './table-header';
import { StyledTableRow, TableRow } from './table-row';
import { type TableColumn, type TableData } from './types';
import { createRowSelectionStateFromSelectedRows, isSameRowSelectionState } from './utils/table-utils';
import { createRowSelectionStateFromSelectedRowIds } from './utils/table-utils';

type RowSize = 'small' | 'medium' | 'large';

Expand All @@ -36,7 +36,7 @@ const enum UtilityColumnId {
Expand = 'expand'
}

type RowSelectionMode = 'single' | 'multiple';
export type RowSelectionMode = 'single' | 'multiple';

function getThPadding(device: DeviceType, rowSize?: RowSize): string {
switch (rowSize) {
Expand Down Expand Up @@ -214,27 +214,41 @@ function getSelectionColumn<T extends object>(
checked = row.getIsSelected();
}

const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const onChange = (event: ChangeEvent<HTMLInputElement>): void => {
const isChecked = event.target.checked;

const updatedSelection = { ...table.getState().rowSelection };
if (isChecked) {
updatedSelection[row.id] = true;
} else {
delete updatedSelection[row.id];
}
table.setRowSelection((oldSelection: RowSelectionState) => {
const updatedSelection = { ...oldSelection };

if (isChecked) {
updatedSelection[row.id] = true;

row.subRows.forEach((sub) => {
updatedSelection[sub.id] = true;
});
} else {
delete updatedSelection[row.id];

row.subRows.forEach((sub) => {
delete updatedSelection[sub.id];
});
}

// Select parent when all children were selected, or deselect parent when any child was deselected
const parentRow = row.getParentRow();

// Select parent when all children were selected, or deselect parent when any child was deselected
const allSelectedIds = Object.keys(updatedSelection);
allSelectedIds.forEach((key) => {
const parentRow = table.getRow(key).getParentRow();
if (parentRow && parentRow.subRows.length > 0) {
const allChildrenChecked = parentRow.subRows.every((sub) => allSelectedIds.includes(sub.id));
parentRow.toggleSelected(allChildrenChecked, { selectChildren: false });
const allChildrenChecked = parentRow.subRows.every((sub) => updatedSelection[sub.id]);

if (allChildrenChecked) {
updatedSelection[parentRow.id] = true;
} else {
delete updatedSelection[parentRow.id];
}
}
});

row.toggleSelected(isChecked);
return updatedSelection;
});

// auto-expand
const hasChildren = row.subRows.length > 0;
Expand All @@ -261,6 +275,12 @@ function getSelectionColumn<T extends object>(
const radioBtnName = `row-radiobutton-${uuid()}`;

column.cell = ({ table, row }) => {
const onChange = (): void => {
table.setRowSelection(() => ({
[row.id]: true,
}));
};

const radioBtnId = `row-radiobutton-${row.id}`;
return (
<StyledRadioInput
Expand All @@ -271,10 +291,7 @@ function getSelectionColumn<T extends object>(
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
name={radioBtnName}
onChange={() => {
table.toggleAllRowsSelected(false);
row.toggleSelected(true);
}}
onChange={onChange}
/>
);
};
Expand Down Expand Up @@ -394,7 +411,15 @@ export const Table = <T extends object>({
const [sorting, setSorting] = useState<SortingState>(defaultSort ? [defaultSort] : []);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expanded, setExpanded] = useState<ExpandedState>({});
const [previousSelectedRows, setPreviousSelectedRows] = useState<TableRowId[] | undefined>();
const [previousSelectedRowIds, setPreviousSelectedRowIds] = useState<TableRowId[]>([]);
const tableInstance = useRef<ReturnType<typeof useReactTable<T>>>();

function hasSelectedIdsChanged(rowIds: TableRowId[]): boolean {
return previousSelectedRowIds !== rowIds && (
previousSelectedRowIds.length !== rowIds.length
|| previousSelectedRowIds.some((value) => !rowIds.includes(value))
);
}

// extends columns with utility column if needed (for row numbers and row selection)
const columns = useMemo(() => {
Expand Down Expand Up @@ -428,8 +453,6 @@ export const Table = <T extends object>({
expanded,
]);

const getRowId = (row: T): TableRowId => row[rowIdField] as TableRowId;

const tableOptions: TableOptions<T> = {
data,
columns,
Expand All @@ -441,7 +464,7 @@ export const Table = <T extends object>({
enableMultiSort: false,
manualSorting: manualSort,
getCoreRowModel: getCoreRowModel(),
getRowId,
getRowId: (row: T): TableRowId => row[rowIdField] as TableRowId,
getSortedRowModel: getSortedRowModel(),
getSubRows: (originalRow) => (originalRow as TableData<T>).subRows,
getExpandedRowModel: getExpandedRowModel(),
Expand All @@ -465,41 +488,46 @@ export const Table = <T extends object>({
setExpanded(newValue);
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onRowSelectionChange: (updater: Updater<RowSelectionState>) => {
const newRowSelection = functionalUpdate(updater, rowSelection);
const newSelectedRowIds = Object.keys(newRowSelection);

setRowSelection(newRowSelection);
setPreviousSelectedRowIds(newSelectedRowIds);

const currentTable = tableInstance.current;

if (currentTable) {
const emittedRowIds = newSelectedRowIds.filter(
(rowId) => !excludeGroupsFromSelection || !currentTable.getRow(rowId).subRows.length,
);

onSelectedRowIdsChange?.(emittedRowIds);

if (onSelectedRowsChange) {
const emittedSelectedRows = emittedRowIds
.map((rowId) => currentTable.getRow(rowId))
.map((row) => row.original);

onSelectedRowsChange(emittedSelectedRows);
}
}
},
};

const table = useReactTable(tableOptions);
const hasFooter = columns.some((column) => 'footer' in column);
const currentRowSelection = table.getState().rowSelection;

useEffect(() => {
if (rowSelectionMode && (onSelectedRowsChange || onSelectedRowIdsChange)) {
const newSelectedRows = Object.keys(currentRowSelection)
.map((rowId) => table.getRow(rowId))
.filter((row) => !excludeGroupsFromSelection || row.subRows.length === 0);
tableInstance.current = table;

onSelectedRowIdsChange?.(newSelectedRows.map((row) => row.id));
onSelectedRowsChange?.(newSelectedRows.map((row) => row.original));
}
}, [
rowSelectionMode,
currentRowSelection,
excludeGroupsFromSelection,
onSelectedRowsChange,
table,
onSelectedRowIdsChange,
]);
const hasFooter = columns.some((column) => 'footer' in column);

if (selectedRowIds !== undefined && previousSelectedRows !== selectedRowIds && rowSelectionMode !== undefined) {
const newSelection: RowSelectionState = createRowSelectionStateFromSelectedRows(
if (rowSelectionMode !== undefined && selectedRowIds !== undefined && hasSelectedIdsChanged(selectedRowIds)) {
const newSelection: RowSelectionState = createRowSelectionStateFromSelectedRowIds(
selectedRowIds,
rowSelectionMode,
);

if (!isSameRowSelectionState(currentRowSelection, newSelection)) {
setRowSelection(newSelection);
}
setPreviousSelectedRows(selectedRowIds);
setRowSelection(newSelection);
setPreviousSelectedRowIds(selectedRowIds);
}

return (
Expand Down
39 changes: 1 addition & 38 deletions packages/react/src/components/table/utils/table-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type RowSelectionState } from '@tanstack/react-table';
import { calculateStickyColumns, calculateStickyHeader, isSameRowSelectionState } from './table-utils';
import { calculateStickyColumns, calculateStickyHeader } from './table-utils';

function getTable(): HTMLTableElement {
const table: HTMLTableElement = document.createElement('table');
Expand Down Expand Up @@ -39,42 +38,6 @@ function getTableValues(): {
}

describe('Table utils', () => {
describe('isSameRowSelectionState', () => {
test('should return true for identical objects', () => {
const obj1: RowSelectionState = { row1: true, row2: false };
const obj2: RowSelectionState = { row1: true, row2: false };

expect(isSameRowSelectionState(obj1, obj2)).toBe(true);
});

test('should return false for objects with different keys', () => {
const obj1: RowSelectionState = { row1: true, row2: false };
const obj2: RowSelectionState = { row1: true, row3: false };

expect(isSameRowSelectionState(obj1, obj2)).toBe(false);
});

test('should return false for objects with different values', () => {
const obj1: RowSelectionState = { row1: true, row2: false };
const obj2: RowSelectionState = { row1: true, row2: true };

expect(isSameRowSelectionState(obj1, obj2)).toBe(false);
});

test('should return true for the same object reference', () => {
const obj1: RowSelectionState = { row1: true, row2: false };

expect(isSameRowSelectionState(obj1, obj1)).toBe(true);
});

test('should return false for objects with different lengths', () => {
const obj1: RowSelectionState = { row1: true };
const obj2: RowSelectionState = { row1: true, row2: false };

expect(isSameRowSelectionState(obj1, obj2)).toBe(false);
});
});

describe('calculateStickyColumns', () => {
test('should set header cell z-index when column is sticky', () => {
const { headerCells, rows } = getTableValues();
Expand Down
31 changes: 7 additions & 24 deletions packages/react/src/components/table/utils/table-utils.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,14 @@
import { type Column, type RowSelectionState } from '@tanstack/react-table';
import { type TableRowId } from '../table';
import { type RowSelectionMode, type TableRowId } from '../table';

export function isSameRowSelectionState(obj1: RowSelectionState, obj2: RowSelectionState): boolean {
if (obj1 === obj2) {
return true;
}

const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}

for (let i = 0; i < keys1.length; i++) {
const key = keys1[i];
if (obj1[key] !== obj2[key]) {
return false;
}
}

return true;
}

export function createRowSelectionStateFromSelectedRows(
export function createRowSelectionStateFromSelectedRowIds(
selectedRowIds: TableRowId[],
rowSelectionMode: 'single' | 'multiple',
rowSelectionMode: RowSelectionMode | undefined,
): RowSelectionState {
if (rowSelectionMode === undefined) {
return {};
}

if (rowSelectionMode === 'single' && selectedRowIds.length > 1) {
return {
[selectedRowIds[0]]: true,
Expand Down

0 comments on commit 6e96803

Please sign in to comment.