InteractiveTable: Add expand all to column (#84966)

* feat: add expand all to InteractiveTable

* chore: pr feedback; type tightening and better testing practice

* chore: pr feedback; type cleanup
This commit is contained in:
Adam Bannach
2024-03-22 15:55:04 +02:00
committed by GitHub
parent 369cb5b6eb
commit 16737c5b27
9 changed files with 303 additions and 209 deletions
+2
View File
@@ -217,6 +217,8 @@ export const availableIconsIndex = {
sync: true,
'sync-slash': true,
table: true,
'table-collapse-all': true,
'table-expand-all': true,
'tag-alt': true,
'telegram-alt': true,
'text-fields': true,
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { CellProps, HeaderProps } from 'react-table';
import { IconButton } from '../IconButton/IconButton';
import { IconButton } from '../../IconButton/IconButton';
const expanderContainerStyles = css({
display: 'flex',
@@ -27,3 +27,18 @@ export function ExpanderCell<K extends object>({ row, __rowID }: CellProps<K, vo
</div>
);
}
export function ExpanderHeader<K extends object>({ isAllRowsExpanded, toggleAllRowsExpanded }: HeaderProps<K>) {
return (
<div className={expanderContainerStyles}>
<IconButton
aria-label={!isAllRowsExpanded ? 'Expand all rows' : 'Collapse all rows'}
name={!isAllRowsExpanded ? 'table-expand-all' : 'table-collapse-all'}
onClick={() => toggleAllRowsExpanded()}
size={'lg'}
tooltip={!isAllRowsExpanded ? 'Expand all rows' : 'Collapse all rows'}
variant={'secondary'}
/>
</div>
);
}
@@ -111,6 +111,7 @@ export const MyComponent = () => {
data={tableData}
getRowId={(r) => r.datasource}
renderExpandedRow={ExpandedCell}
showExpandAll
/>
);
};
@@ -1,151 +1,22 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useCallback, useMemo, useState } from 'react';
import { Meta, StoryFn, StoryObj } from '@storybook/react';
import React, { useCallback, useState } from 'react';
import { InteractiveTable, Column, CellProps, LinkButton } from '@grafana/ui';
import { InteractiveTable, CellProps, LinkButton } from '@grafana/ui';
import { FetchDataArgs, InteractiveTableHeaderTooltip } from './InteractiveTable';
import mdx from './InteractiveTable.mdx';
const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId', 'fetchData'];
const meta: Meta<typeof InteractiveTable> = {
title: 'Experimental/InteractiveTable',
component: InteractiveTable,
parameters: {
docs: {
page: mdx,
},
controls: {
exclude: EXCLUDED_PROPS,
},
},
args: {},
argTypes: {},
};
interface TableData {
header1: string;
header2?: number;
noheader?: string;
}
export const Basic: StoryFn<typeof InteractiveTable> = (args) => {
const columns = useMemo<Array<Column<TableData>>>(
() => [
{ id: 'header2', header: 'With missing values', sortType: 'number', disableGrow: true },
{
id: 'noheader',
sortType: 'number',
},
],
[]
);
const data: TableData[] = useMemo(
() => [
{ header1: 'a', header2: 1 },
{ header1: 'b', noheader: "This column doesn't have an header" },
{ header1: 'c', noheader: "But it's still sortable" },
],
[]
);
return <InteractiveTable columns={columns} data={data} getRowId={(r) => r.header1} />;
};
interface WithRowExpansionData {
datasource: string;
repo: string;
description: string;
}
const ExpandedCell = ({ description }: WithRowExpansionData) => {
return <p>{description}</p>;
};
export const WithRowExpansion: StoryFn<typeof InteractiveTable> = (args) => {
const tableData: WithRowExpansionData[] = [
{
datasource: 'Prometheus',
repo: 'https://github.com/prometheus/prometheus',
description: 'Open source time series database & alerting.',
},
{
datasource: 'Loki',
repo: 'https://github.com/grafana/loki',
description: 'Like Prometheus but for logs. OSS logging solution from Grafana Labs.',
},
{
datasource: 'Tempo',
repo: 'https://github.com/grafana/tempo',
description: 'High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.',
},
];
const columns: Array<Column<WithRowExpansionData>> = [
{ id: 'datasource', header: 'Data Source' },
{ id: 'repo', header: 'Repo' },
];
return (
<InteractiveTable
columns={columns}
data={tableData}
getRowId={(r) => r.datasource}
renderExpandedRow={ExpandedCell}
/>
);
};
interface WithCustomCellData {
datasource: string;
repo: string;
}
const RepoCell = ({
row: {
original: { repo },
},
}: CellProps<WithCustomCellData, void>) => {
return (
<LinkButton href={repo} size="sm" icon="external-link-alt">
Open on GithHub
</LinkButton>
);
};
export const WithCustomCell: StoryFn<typeof InteractiveTable> = (args) => {
const tableData: WithCustomCellData[] = [
{
datasource: 'Prometheus',
repo: 'https://github.com/prometheus/prometheus',
},
{
datasource: 'Loki',
repo: 'https://github.com/grafana/loki',
},
{
datasource: 'Tempo',
repo: 'https://github.com/grafana/tempo',
},
];
const columns: Array<Column<WithCustomCellData>> = [
{ id: 'datasource', header: 'Data Source' },
{ id: 'repo', header: 'Repo', cell: RepoCell },
];
return <InteractiveTable columns={columns} data={tableData} getRowId={(r) => r.datasource} />;
};
interface WithPaginationData {
interface CarData {
id: string;
firstName: string;
lastName: string;
car: string;
car?: string;
age: number;
}
const pageableData: WithPaginationData[] = [
const pageableData: CarData[] = [
{ id: '48a3926a-e82c-4c26-b959-3a5f473e186e', firstName: 'Brynne', lastName: 'Denisevich', car: 'Cougar', age: 47 },
{
id: 'cf281390-adbf-4407-8cf3-a52e012f63e6',
@@ -228,60 +99,155 @@ const pageableData: WithPaginationData[] = [
{ id: 'b9b0b559-acc1-4bd8-b052-160ecf3e4f68', firstName: 'Ermanno', lastName: 'Sinott', car: 'Thunderbird', age: 26 },
];
export const WithPagination: StoryFn<typeof InteractiveTable> = (args) => {
const columns: Array<Column<WithPaginationData>> = [
{ id: 'firstName', header: 'First name' },
{ id: 'lastName', header: 'Last name' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age', sortType: 'number' },
];
return <InteractiveTable columns={columns} data={pageableData} getRowId={(r) => r.id} pageSize={15} />;
const meta: Meta<typeof InteractiveTable<CarData>> = {
title: 'Experimental/InteractiveTable',
component: InteractiveTable,
parameters: {
docs: {
page: mdx,
},
controls: {
exclude: EXCLUDED_PROPS,
},
},
args: {
columns: [
{ id: 'firstName', header: 'First name', sortType: 'string' },
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age' },
],
data: pageableData.slice(0, 10),
getRowId: (r) => r.id,
pageSize: 0,
showExpandAll: false,
},
argTypes: {},
};
export const WithHeaderTooltips: StoryFn<typeof InteractiveTable> = (args) => {
const columns: Array<Column<WithPaginationData>> = [
{ id: 'firstName', header: 'First name' },
{ id: 'lastName', header: 'Last name' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age', sortType: 'number' },
];
type TableStory = StoryObj<typeof InteractiveTable<CarData>>;
const headerTooltips: Record<string, InteractiveTableHeaderTooltip> = {
age: { content: 'The number of years since the person was born' },
lastName: {
content: () => {
return (
<>
<h4>Here is an h4</h4>
<div>Some content</div>
<div>Some more content</div>
</>
);
export const Basic: TableStory = {
args: {
columns: [
{
id: 'firstName',
header: 'First name',
sortType: 'alphanumeric',
},
iconName: 'plus-square',
},
};
{
id: 'lastName',
sortType: 'alphanumeric',
},
{ id: 'car', header: 'With missing values', sortType: 'alphanumeric', disableGrow: true },
],
data: [
{
id: 'be5736f5-7015-4668-a03d-44b56f2b012c',
firstName: 'Sonni',
lastName: 'Still sortable!',
car: 'Legend',
age: 75,
},
{ id: 'fdbe3559-c68a-4f2f-b579-48ef02642628', firstName: 'Hanson', lastName: 'Giraudeau', car: 'X5', age: 67 },
{ id: '7d0ee01a-7ac5-4e0a-9c73-e864d10c0152', firstName: 'Whitman', lastName: 'Seabridge', age: 99 },
{ id: '177c2287-b7cb-4b5f-8976-56ee993bed61', firstName: 'Aleda', lastName: 'Friman', car: 'X5', age: 44 },
{ id: '87c21e60-c2f4-4a01-b2af-a6d22c196e25', firstName: 'Cullen', lastName: 'Kobpac', car: 'Montero', age: 28 },
{ id: 'dd89f32d-2ef4-4c35-8e23-a8b2219e3a69', firstName: 'Fitz', lastName: 'Butterwick', car: 'Fox', age: 70 },
{
id: 'cc1b4de7-8ec5-49bd-93bc-bee9fa1ccf37',
firstName: 'Jordon',
lastName: 'Harrington',
car: 'Elantra',
age: 39,
},
],
},
};
const ExpandedCell = ({ car }: CarData) => {
return <p>{car}</p>;
};
export const WithRowExpansion: TableStory = {
args: {
renderExpandedRow: ExpandedCell,
},
};
interface WithCustomCellData {
datasource: string;
repo: string;
}
const RepoCell = ({
row: {
original: { repo },
},
}: CellProps<WithCustomCellData, void>) => {
return (
<InteractiveTable
columns={columns}
data={pageableData.slice(0, 10)}
getRowId={(r) => r.id}
headerTooltips={headerTooltips}
/>
<LinkButton href={repo} size="sm" icon="external-link-alt">
Open on GithHub
</LinkButton>
);
};
export const WithCustomCell: StoryObj<typeof InteractiveTable<WithCustomCellData>> = {
args: {
columns: [
{ id: 'datasource', header: 'Data Source' },
{ id: 'repo', header: 'Repo', cell: RepoCell },
],
data: [
{
datasource: 'Prometheus',
repo: 'https://github.com/prometheus/prometheus',
},
{
datasource: 'Loki',
repo: 'https://github.com/grafana/loki',
},
{
datasource: 'Tempo',
repo: 'https://github.com/grafana/tempo',
},
],
getRowId: (r) => r.datasource,
},
};
export const WithPagination: TableStory = {
args: {
pageSize: 15,
data: pageableData,
},
};
const headerTooltips: Record<string, InteractiveTableHeaderTooltip> = {
age: { content: 'The number of years since the person was born' },
lastName: {
content: () => {
return (
<>
<h4>Here is an h4</h4>
<div>Some content</div>
<div>Some more content</div>
</>
);
},
iconName: 'plus-square',
},
};
export const WithHeaderTooltips: TableStory = {
args: {
headerTooltips,
},
};
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>) => {
const fetchData = useCallback(({ sortBy }: FetchDataArgs<CarData>) => {
if (!sortBy?.length) {
return setData(pageableData);
}
@@ -291,9 +257,9 @@ export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
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'>];
const aData = a[sort.id as keyof Omit<CarData, 'age' | 'car'>];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const bData = b[sort.id as keyof Omit<WithPaginationData, 'age'>];
const bData = b[sort.id as keyof Omit<CarData, 'age' | 'car'>];
if (sort.desc) {
return bData.localeCompare(aData);
}
@@ -303,6 +269,7 @@ export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
}, 300);
}, []);
return <InteractiveTable columns={columns} data={data} getRowId={(r) => r.id} pageSize={15} fetchData={fetchData} />;
return <InteractiveTable {...args} data={data} pageSize={15} fetchData={fetchData} />;
};
export default meta;
@@ -77,28 +77,101 @@ describe('InteractiveTable', () => {
expect(valueColumnHeader).not.toHaveAttribute('aria-sort');
expect(countryColumnHeader).not.toHaveAttribute('aria-sort');
});
describe('row expansion', () => {
it('correctly expands rows', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
const { user } = setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
/>
);
it('correctly expands rows', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
const { user } = setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
/>
);
const expanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
await user.click(expanderButton);
const expanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
await user.click(expanderButton);
expect(screen.getByTestId('test-1')).toHaveTextContent('Sweden');
expect(screen.getByTestId('test-1')).toHaveTextContent('Sweden');
expect(expanderButton.getAttribute('aria-controls')).toBe(
// ancestor tr's id should match the expander button's aria-controls attribute
screen.getByTestId('test-1').parentElement?.parentElement?.id
);
});
it('does not render expand all when showExpandAll is false', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
showExpandAll={false}
/>
);
expect(expanderButton.getAttribute('aria-controls')).toBe(
// ancestor tr's id should match the expander button's aria-controls attribute
screen.getByTestId('test-1').parentElement?.parentElement?.id
);
expect(screen.queryByRole('button', { name: 'Expand all rows' })).not.toBeInTheDocument();
});
it('does not render expand all when showExpandAll is not provided', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
/>
);
expect(screen.queryByRole('button', { name: 'Expand all rows' })).not.toBeInTheDocument();
});
it('renders expand all when showExpandAll is true', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
showExpandAll
/>
);
expect(screen.getByRole('button', { name: 'Expand all rows' })).toBeInTheDocument();
});
it('expands all rows when expand all is clicked', async () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [
{ id: '1', value: '1', country: 'Sweden' },
{ id: '2', value: '2', country: 'Belgium' },
{ id: '3', value: '3', country: 'France' },
];
const { user } = setup(
<InteractiveTable
columns={columns}
data={data}
getRowId={getRowId}
renderExpandedRow={(row) => <div data-testid={`test-${row.id}`}>{row.country}</div>}
showExpandAll
/>
);
expect(screen.queryByTestId('test-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('test-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('test-3')).not.toBeInTheDocument();
const expandAllButton = screen.getByRole('button', { name: 'Expand all rows' });
await user.click(expandAllButton);
expect(screen.queryByTestId('test-1')).toBeInTheDocument();
expect(screen.queryByTestId('test-2')).toBeInTheDocument();
expect(screen.queryByTestId('test-3')).toBeInTheDocument();
});
});
describe('pagination', () => {
it('does not render pagination controls if pageSize is not set', () => {
@@ -120,7 +120,8 @@ export type InteractiveTableHeaderTooltip = {
export type FetchDataArgs<Data> = { sortBy: Array<SortingRule<Data>> };
export type FetchDataFunc<Data> = ({ sortBy }: FetchDataArgs<Data>) => void;
interface Props<TableData extends object> {
interface BaseProps<TableData extends object> {
className?: string;
/**
* Table's columns definition. Must be memoized.
@@ -142,10 +143,6 @@ interface Props<TableData extends object> {
* Number of rows per page. A value of zero disables pagination. Defaults to 0.
*/
pageSize?: number;
/**
* 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
@@ -154,6 +151,24 @@ interface Props<TableData extends object> {
fetchData?: FetchDataFunc<TableData>;
}
interface WithExpandableRow<TableData extends object> extends BaseProps<TableData> {
/**
* Render function for the expanded row. if not provided, the tables rows will not be expandable.
*/
renderExpandedRow: (row: TableData) => ReactNode;
/**
* Whether to show the "Expand all" button. Depends on renderExpandedRow to be provided. Defaults to false.
*/
showExpandAll?: boolean;
}
interface WithoutExpandableRow<TableData extends object> extends BaseProps<TableData> {
renderExpandedRow?: never;
showExpandAll?: never;
}
type Props<TableData extends object> = WithExpandableRow<TableData> | WithoutExpandableRow<TableData>;
/** @alpha */
export function InteractiveTable<TableData extends object>({
className,
@@ -163,12 +178,13 @@ export function InteractiveTable<TableData extends object>({
headerTooltips,
pageSize = 0,
renderExpandedRow,
showExpandAll = false,
fetchData,
}: Props<TableData>) {
const styles = useStyles2(getStyles);
const tableColumns = useMemo(() => {
return getColumns<TableData>(columns);
}, [columns]);
return getColumns<TableData>(columns, showExpandAll);
}, [columns, showExpandAll]);
const id = useUniqueId();
const getRowHTMLID = useCallback(
(row: Row<TableData>) => {
@@ -1,6 +1,6 @@
import { Column as RTColumn } from 'react-table';
import { ExpanderCell } from './ExpanderCell';
import { ExpanderCell, ExpanderHeader } from './Expander';
import { Column } from './types';
export const EXPANDER_CELL_ID = '__expander' as const;
@@ -10,11 +10,17 @@ type InternalColumn<T extends object> = RTColumn<T> & {
};
// Returns the columns in a "react-table" acceptable format
export function getColumns<K extends object>(columns: Array<Column<K>>): Array<InternalColumn<K>> {
export function getColumns<K extends object>(
columns: Array<Column<K>>,
showExpandAll = false
): Array<InternalColumn<K>> {
return [
{
id: EXPANDER_CELL_ID,
Cell: ExpanderCell,
...(showExpandAll && {
Header: ExpanderHeader,
}),
disableSortBy: true,
width: 0,
},
@@ -0,0 +1,7 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.35355 7.64645C8.15829 7.45118 7.84171 7.45118 7.64645 7.64645L4.46447 10.8284C4.2692 11.0237 4.2692 11.3403 4.46447 11.5355C4.65973 11.7308 4.97631 11.7308 5.17157 11.5355L8 8.70711L10.8284 11.5355C11.0237 11.7308 11.3403 11.7308 11.5355 11.5355C11.7308 11.3403 11.7308 11.0237 11.5355 10.8284L8.35355 7.64645ZM8.5 14L8.5 8L7.5 8L7.5 14L8.5 14Z"
fill="currentColor"/>
<line x1="2" y1="5.5" x2="14" y2="5.5" stroke="currentColor"/>
<line x1="2" y1="2.5" x2="14" y2="2.5" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 588 B

@@ -0,0 +1,7 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="transparent" d="M2.5 5.5h11v2H2.5Z"/>
<path stroke="currentColor" fill="transparent" d="M2.5 1.5h11v2H2.5Z"/>
<path
d="M7.64645 14.3536C7.84171 14.5488 8.15829 14.5488 8.35355 14.3536L11.5355 11.1716C11.7308 10.9763 11.7308 10.6597 11.5355 10.4645C11.3403 10.2692 11.0237 10.2692 10.8284 10.4645L8 13.2929L5.17157 10.4645C4.97631 10.2692 4.65973 10.2692 4.46447 10.4645C4.2692 10.6597 4.2692 10.9763 4.46447 11.1716L7.64645 14.3536ZM7.5 8V14H8.5V8H7.5Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 592 B