From cfd468bcdd0c4a7432d0cd4cb07b7b829bb5b6dd Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 22 Sep 2023 17:31:12 +0300 Subject: [PATCH] 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 --- .../InteractiveTable/InteractiveTable.mdx | 51 ++++++++++++++++++- .../InteractiveTable.story.tsx | 39 ++++++++++++-- .../InteractiveTable.test.tsx | 47 +++++++++++++---- .../InteractiveTable/InteractiveTable.tsx | 19 +++++++ packages/grafana-ui/src/components/index.ts | 2 +- 5 files changed, 141 insertions(+), 17 deletions(-) diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx index 095db2b5376..4bde01ab917 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -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>( ### 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 = (args) => { + const columns: Array> = [ + { 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) => { + 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]; + const bData = b[sort.id as keyof Omit]; + if (sort.desc) { + return bData.localeCompare(aData); + } + return aData.localeCompare(bData); + }); + setData(newData); + }, 300); + }, []); + + return r.id} pageSize={15} fetchData={fetchData} />; +}; +``` diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index 49d814f7a01..9d688b1d6d9 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -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 = { title: 'Experimental/InteractiveTable', @@ -272,4 +272,37 @@ export const WithHeaderTooltips: StoryFn = (args) => { ); }; +export const WithControlledSort: StoryFn = (args) => { + const columns: Array> = [ + { 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) => { + 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]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const bData = b[sort.id as keyof Omit]; + if (sort.desc) { + return bData.localeCompare(aData); + } + return aData.localeCompare(bData); + }); + setData(newData); + }, 300); + }, []); + + return r.id} pageSize={15} fetchData={fetchData} />; +}; export default meta; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx index d0d52f3eaca..744a73c791c 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -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> = [ @@ -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(); + const { user } = setup(); 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> = [{ id: 'id', header: 'ID' }]; const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; - render( + const { user } = setup( { ); 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> = [{ id: 'id', header: 'ID', sortType: 'string' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + const fetchData = jest.fn(); + render(); + + const valueColumnHeader = screen.getByRole('button', { + name: /id/i, + }); + + await userEvent.click(valueColumnHeader); + + expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] }); + }); + }); }); diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index 429563d665b..946d9d36779 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -5,6 +5,7 @@ import { HeaderGroup, PluginHook, Row, + SortingRule, TableOptions, useExpanded, usePagination, @@ -117,6 +118,8 @@ export type InteractiveTableHeaderTooltip = { iconName?: IconName; }; +export type FetchDataArgs = { sortBy: Array> }; +export type FetchDataFunc = ({ sortBy }: FetchDataArgs) => void; interface Props { className?: string; /** @@ -143,6 +146,12 @@ interface Props { * 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; } /** @alpha */ @@ -154,6 +163,7 @@ export function InteractiveTable({ headerTooltips, pageSize = 0, renderExpandedRow, + fetchData, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { @@ -182,6 +192,8 @@ export function InteractiveTable({ 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({ const { getTableProps, getTableBodyProps, headerGroups, prepareRow } = tableInstance; + const { sortBy } = tableInstance.state; + useEffect(() => { + if (fetchData) { + fetchData({ sortBy }); + } + }, [sortBy, fetchData]); + useEffect(() => { if (paginationEnabled) { tableInstance.setPageSize(pageSize); diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b87920c87e9..cee13eb356c 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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';