InteractiveTable: Add controlled sort (#75289)

* InteractiveTable: Enable controlled sort

* InteractiveTable: Add docs and test

* InteractiveTable: Tweak docs

* InteractiveTable: More doc tweaks

* InteractiveTable: Remove assertion rules

* InteractiveTable: Review updates
This commit is contained in:
Alex Khomenko 2023-09-22 17:31:12 +03:00 committed by GitHub
parent 2192a34fc4
commit cfd468bcdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 141 additions and 17 deletions

View File

@ -13,7 +13,7 @@ The InteractiveTable is used to display and select data efficiently.
It allows for the display and modification of detailed information.
With additional functionality it allows for batch editing, as needed by your feature's users.
It is a wrapper around [React Table](https://react-table-v7.tanstack.com/), for more informations about it, refer to the [official documentation](https://react-table.tanstack.com/docs/overview).
It is a wrapper around [React Table](https://react-table-v7.tanstack.com/), for more information, refer to the [official documentation](https://react-table.tanstack.com/docs/overview).
### When to use
@ -77,7 +77,7 @@ const data = useMemo<Array<TableData>>(
### With row expansion
Individual rows can be expanded to display additional details or reconfigure properties previously defined when the row was created.
The expanded row area should be used to declutter the primary presentation of data, carefully consider what the user needs to know at first glance and what can be hidden behind the Row Expander button.
The expanded row area should be used to unclutter the primary presentation of data, carefully consider what the user needs to know at first glance and what can be hidden behind the Row Expander button.
In general, data-types that are consistent across all dataset are in the primary table, variances are pushed to the expanded section for each individual row.
@ -271,3 +271,50 @@ export const MyComponent = () => {
);
};
```
### With controlled sorting
The default sorting can be changed to controlled sorting by passing in the `fetchData` function, which is called whenever the sorting changes and should return the sorted data. This is useful when the sorting is done server side. It is important to memoize the `fetchData` function to prevent unnecessary rerenders and the possibility of an infinite render loop.
```tsx
interface WithPaginationData {
id: string;
firstName: string;
lastName: string;
car: string;
age: number;
}
export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
const columns: Array<Column<WithPaginationData>> = [
{ id: 'firstName', header: 'First name', sortType: 'string' },
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age' },
];
const [data, setData] = useState(pageableData);
// In production the function will most likely make an API call to fetch the sorted data
const fetchData = useCallback(({ sortBy }: FetchDataArgs<WithPaginationData>) => {
if (!sortBy?.length) {
return setData(pageableData);
}
setTimeout(() => {
const newData = [...pageableData];
newData.sort((a, b) => {
const sort = sortBy[0];
const aData = a[sort.id as keyof Omit<WithPaginationData, 'age'>];
const bData = b[sort.id as keyof Omit<WithPaginationData, 'age'>];
if (sort.desc) {
return bData.localeCompare(aData);
}
return aData.localeCompare(bData);
});
setData(newData);
}, 300);
}, []);
return <InteractiveTable columns={columns} data={data} getRowId={(r) => r.id} pageSize={15} fetchData={fetchData} />;
};
```

View File

@ -1,12 +1,12 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { InteractiveTable, Column, CellProps, LinkButton } from '@grafana/ui';
import { InteractiveTableHeaderTooltip } from './InteractiveTable';
import { FetchDataArgs, InteractiveTableHeaderTooltip } from './InteractiveTable';
import mdx from './InteractiveTable.mdx';
const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId'];
const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId', 'fetchData'];
const meta: Meta<typeof InteractiveTable> = {
title: 'Experimental/InteractiveTable',
@ -272,4 +272,37 @@ export const WithHeaderTooltips: StoryFn<typeof InteractiveTable> = (args) => {
);
};
export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
const columns: Array<Column<WithPaginationData>> = [
{ id: 'firstName', header: 'First name', sortType: 'string' },
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age' },
];
const [data, setData] = useState(pageableData);
const fetchData = useCallback(({ sortBy }: FetchDataArgs<WithPaginationData>) => {
if (!sortBy?.length) {
return setData(pageableData);
}
setTimeout(() => {
const newData = [...pageableData];
newData.sort((a, b) => {
const sort = sortBy[0];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const aData = a[sort.id as keyof Omit<WithPaginationData, 'age'>];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const bData = b[sort.id as keyof Omit<WithPaginationData, 'age'>];
if (sort.desc) {
return bData.localeCompare(aData);
}
return aData.localeCompare(bData);
});
setData(newData);
}, 300);
}, []);
return <InteractiveTable columns={columns} data={data} getRowId={(r) => r.id} pageSize={15} fetchData={fetchData} />;
};
export default meta;

View File

@ -1,4 +1,5 @@
import { fireEvent, getByRole, render, screen, cleanup } from '@testing-library/react';
import { getByRole, render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { InteractiveTable } from './InteractiveTable';
@ -13,6 +14,13 @@ function getRowId(row: TableData) {
return row.id;
}
function setup(jsx: React.JSX.Element) {
render(jsx);
return {
user: userEvent.setup(),
};
}
describe('InteractiveTable', () => {
it('should not render hidden columns', () => {
const columns: Array<Column<TableData>> = [
@ -29,7 +37,7 @@ describe('InteractiveTable', () => {
expect(screen.queryByRole('columnheader', { name: 'Country' })).not.toBeInTheDocument();
});
it('should correctly sort rows', () => {
it('should correctly sort rows', async () => {
// We are not testing the sorting logic here since it is already tested in react-table,
// but instead we are testing that the sorting is applied correctly to the table and correct aria attributes are set
// according to https://www.w3.org/WAI/ARIA/apg/example-index/table/sortable-table
@ -43,7 +51,7 @@ describe('InteractiveTable', () => {
{ id: '2', value: '3', country: 'Portugal' },
{ id: '3', value: '2', country: 'Italy' },
];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
const { user } = setup(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
const valueColumnHeader = screen.getByRole('columnheader', { name: 'Value' });
const countryColumnHeader = screen.getByRole('columnheader', { name: 'Country' });
@ -53,27 +61,27 @@ describe('InteractiveTable', () => {
expect(valueColumnHeader).not.toHaveAttribute('aria-sort');
expect(countryColumnHeader).not.toHaveAttribute('aria-sort');
fireEvent.click(countryColumnSortButton);
await user.click(countryColumnSortButton);
expect(valueColumnHeader).not.toHaveAttribute('aria-sort');
expect(countryColumnHeader).toHaveAttribute('aria-sort', 'ascending');
fireEvent.click(valueColumnSortButton);
await user.click(valueColumnSortButton);
expect(valueColumnHeader).toHaveAttribute('aria-sort', 'ascending');
expect(countryColumnHeader).not.toHaveAttribute('aria-sort');
fireEvent.click(valueColumnSortButton);
await user.click(valueColumnSortButton);
expect(valueColumnHeader).toHaveAttribute('aria-sort', 'descending');
expect(countryColumnHeader).not.toHaveAttribute('aria-sort');
fireEvent.click(valueColumnSortButton);
await user.click(valueColumnSortButton);
expect(valueColumnHeader).not.toHaveAttribute('aria-sort');
expect(countryColumnHeader).not.toHaveAttribute('aria-sort');
});
it('correctly expands rows', () => {
it('correctly expands rows', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(
const { user } = setup(
<InteractiveTable
columns={columns}
data={data}
@ -83,12 +91,12 @@ describe('InteractiveTable', () => {
);
const expanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
fireEvent.click(expanderButton);
await user.click(expanderButton);
expect(screen.getByTestId('test-1')).toHaveTextContent('Sweden');
expect(expanderButton.getAttribute('aria-controls')).toBe(
// anchestor tr's id should match the expander button's aria-controls attribute
// ancestor tr's id should match the expander button's aria-controls attribute
screen.getByTestId('test-1').parentElement?.parentElement?.id
);
});
@ -137,4 +145,21 @@ describe('InteractiveTable', () => {
expect(screen.getByTestId('header-tooltip-icon')).toBeInTheDocument();
});
});
describe('controlled sort', () => {
it('should call fetchData with the correct sortBy argument', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID', sortType: 'string' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
const fetchData = jest.fn();
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} fetchData={fetchData} />);
const valueColumnHeader = screen.getByRole('button', {
name: /id/i,
});
await userEvent.click(valueColumnHeader);
expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] });
});
});
});

View File

@ -5,6 +5,7 @@ import {
HeaderGroup,
PluginHook,
Row,
SortingRule,
TableOptions,
useExpanded,
usePagination,
@ -117,6 +118,8 @@ export type InteractiveTableHeaderTooltip = {
iconName?: IconName;
};
export type FetchDataArgs<Data> = { sortBy: Array<SortingRule<Data>> };
export type FetchDataFunc<Data> = ({ sortBy }: FetchDataArgs<Data>) => void;
interface Props<TableData extends object> {
className?: string;
/**
@ -143,6 +146,12 @@ interface Props<TableData extends object> {
* Render function for the expanded row. if not provided, the tables rows will not be expandable.
*/
renderExpandedRow?: (row: TableData) => ReactNode;
/**
* A custom function to fetch data when the table is sorted. If not provided, the table will be sorted client-side.
* It's important for this function to have a stable identity, e.g. being wrapped into useCallback to prevent unnecessary
* re-renders of the table.
*/
fetchData?: FetchDataFunc<TableData>;
}
/** @alpha */
@ -154,6 +163,7 @@ export function InteractiveTable<TableData extends object>({
headerTooltips,
pageSize = 0,
renderExpandedRow,
fetchData,
}: Props<TableData>) {
const styles = useStyles2(getStyles);
const tableColumns = useMemo(() => {
@ -182,6 +192,8 @@ export function InteractiveTable<TableData extends object>({
autoResetExpanded: false,
autoResetSortBy: false,
disableMultiSort: true,
// If fetchData is provided, we disable client-side sorting
manualSortBy: Boolean(fetchData),
getRowId,
initialState: {
hiddenColumns: [
@ -198,6 +210,13 @@ export function InteractiveTable<TableData extends object>({
const { getTableProps, getTableBodyProps, headerGroups, prepareRow } = tableInstance;
const { sortBy } = tableInstance.state;
useEffect(() => {
if (fetchData) {
fetchData({ sortBy });
}
}, [sortBy, fetchData]);
useEffect(() => {
if (paginationEnabled) {
tableInstance.setPageSize(pageSize);

View File

@ -46,7 +46,7 @@ export {
} from './DateTimePickers/DatePickerWithInput/DatePickerWithInput';
export { DateTimePicker } from './DateTimePickers/DateTimePicker/DateTimePicker';
export { List } from './List/List';
export { InteractiveTable } from './InteractiveTable/InteractiveTable';
export { InteractiveTable, type FetchDataArgs, type FetchDataFunc } from './InteractiveTable/InteractiveTable';
export { TagsInput } from './TagsInput/TagsInput';
export { AutoSaveField } from './AutoSaveField/AutoSaveField';
export { Pagination } from './Pagination/Pagination';