mirror of https://github.com/grafana/grafana
Grafana UI: Add experimental InteractiveTable component (#58223)
* wip * move table * refine example * move to experimental * add row expansion example * add expanded row to kitchen sink * add column prop docs * add props docs * remove useless example * WIP * use unique id per row & proper aria attrs for expander * add custom cell rendering example * Remove multisort * rename shrink to disableGrow * move isTruthy type guard to @grafana/data * add missing prop from TableData interface * make column id required * fix correlations table * expand on docs * remove leftover comment * rename to InteractiveTable * add some tests * add expansion tests * fix tests * revert unneeded changes * remove extra header rulepull/59514/head
parent
191ca1df86
commit
dc918f7e91
@ -0,0 +1,166 @@ |
||||
import { Meta, Props, Story, Canvas } from '@storybook/addon-docs/blocks'; |
||||
|
||||
import { InteractiveTable } from './InteractiveTable'; |
||||
import { Badge } from '../Badge/Badge'; |
||||
|
||||
<Meta title="MDX|InteractiveTable" component={InteractiveTable} /> |
||||
|
||||
# InteractiveTable |
||||
|
||||
<Badge text="Alpha" icon="rocket" color="blue" tooltip="This component is still experimental." /> |
||||
|
||||
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). |
||||
|
||||
### When to use |
||||
|
||||
The InteractiveTable can be used to allow users to perform administrative tasks workflows. |
||||
|
||||
### When not to use |
||||
|
||||
Avoid using the InteractiveTable where mobile or responsiveness may be a requirement. |
||||
Consider an alternative pattern where the user is presented with a summary list and can click/tap to an individual page for each row in that list. |
||||
|
||||
### Usage |
||||
|
||||
<Props of={InteractiveTable} /> |
||||
|
||||
#### About `columns` and `data` Props |
||||
|
||||
To avoid unnecessary rerenders, `columns` and `data` must be memoized. |
||||
|
||||
Columns are rendered in the same order defined in the `columns` prop. |
||||
Each Cell's content is automatically rendered by matching the `id` of the column to the key of each object in the `data` array prop. |
||||
|
||||
##### Example |
||||
|
||||
```tsx |
||||
interface TableData { |
||||
projectName: string; |
||||
repository: string; |
||||
} |
||||
|
||||
const columns = useMemo<Array<Column<TableData>>>( |
||||
() => [ |
||||
id: 'projectName' |
||||
header: "Project Name" |
||||
], |
||||
[ |
||||
id: 'repository', |
||||
header: "Repository" |
||||
], |
||||
[] |
||||
); |
||||
|
||||
const data = useMemo<Array<TableData>>( |
||||
() => [ |
||||
{ |
||||
projectName: 'Grafana', |
||||
repository: 'https://github.com/grafana/grafana', |
||||
} |
||||
], |
||||
[ |
||||
{ |
||||
projectName: 'Loki'; |
||||
repository: 'https://github.com/grafana/loki'; |
||||
} |
||||
], |
||||
[] |
||||
); |
||||
``` |
||||
|
||||
## Examples |
||||
|
||||
### 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. |
||||
|
||||
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. |
||||
|
||||
<Story id="experimental-interactivetable--with-row-expansion" /> |
||||
|
||||
Row expansion is enabled whenever the `renderExpanded` prop is provided. The `renderExpanded` function is called with the row's data and should return a ReactNode. |
||||
|
||||
```tsx |
||||
interface TableData { |
||||
datasource: string; |
||||
repo: string; |
||||
description: string; |
||||
} |
||||
|
||||
const tableData: TableData[] = [ |
||||
//... |
||||
]; |
||||
|
||||
const columns: Array<Column<TableData>> = [ |
||||
//... |
||||
]; |
||||
|
||||
const ExpandedCell = ({ description }: TableData) => { |
||||
return <p>{description}</p>; |
||||
}; |
||||
|
||||
export const MyComponent = () => { |
||||
return ( |
||||
<InteractiveTable |
||||
columns={columns} |
||||
data={tableData} |
||||
getRowId={(r) => r.datasource} |
||||
renderExpandedRow={ExpandedCell} |
||||
/> |
||||
); |
||||
}; |
||||
``` |
||||
|
||||
### Custom Cell Rendering |
||||
|
||||
Individual cells can be rendered using custom content dy defining a `cell` property on the column definition. |
||||
|
||||
<Story id="experimental-interactivetable--with-custom-cell" /> |
||||
|
||||
```tsx |
||||
interface TableData { |
||||
datasource: string; |
||||
repo: string; |
||||
} |
||||
|
||||
const RepoCell = ({ |
||||
row: { |
||||
original: { repo }, |
||||
}, |
||||
}: CellProps<WithCustomCellData, void>) => { |
||||
return ( |
||||
<LinkButton href={repo} size="sm" icon="external-link-alt"> |
||||
Open on GitHub |
||||
</LinkButton> |
||||
); |
||||
}; |
||||
|
||||
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 }, |
||||
]; |
||||
|
||||
export const MyComponent = () => { |
||||
return <InteractiveTable columns={columns} data={tableData} getRowId={(r) => r.datasource} />; |
||||
}; |
||||
``` |
||||
@ -0,0 +1,142 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React, { useMemo } from 'react'; |
||||
|
||||
import { InteractiveTable, Column, CellProps, LinkButton } from '@grafana/ui'; |
||||
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
|
||||
import mdx from './InteractiveTable.mdx'; |
||||
|
||||
const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId']; |
||||
|
||||
const meta: ComponentMeta<typeof InteractiveTable> = { |
||||
title: 'Experimental/InteractiveTable', |
||||
component: InteractiveTable, |
||||
decorators: [withCenteredStory], |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
controls: { |
||||
exclude: EXCLUDED_PROPS, |
||||
}, |
||||
}, |
||||
args: {}, |
||||
argTypes: {}, |
||||
}; |
||||
|
||||
interface TableData { |
||||
header1: string; |
||||
header2?: number; |
||||
noheader?: string; |
||||
} |
||||
|
||||
export const Basic: ComponentStory<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: ComponentStory<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: ComponentStory<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} />; |
||||
}; |
||||
|
||||
export default meta; |
||||
@ -0,0 +1,95 @@ |
||||
import { fireEvent, getByRole, render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { InteractiveTable } from './InteractiveTable'; |
||||
import { Column } from './types'; |
||||
|
||||
interface TableData { |
||||
id: string; |
||||
country?: string; |
||||
value?: string; |
||||
} |
||||
function getRowId(row: TableData) { |
||||
return row.id; |
||||
} |
||||
|
||||
describe('InteractiveTable', () => { |
||||
it('should not render hidden columns', () => { |
||||
const columns: Array<Column<TableData>> = [ |
||||
{ id: 'id', header: 'ID' }, |
||||
{ id: 'country', header: 'Country', visible: () => false }, |
||||
]; |
||||
const data: TableData[] = [ |
||||
{ id: '1', country: 'Sweden' }, |
||||
{ id: '2', country: 'Portugal' }, |
||||
]; |
||||
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />); |
||||
|
||||
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('columnheader', { name: 'Country' })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should correctly sort rows', () => { |
||||
// 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
|
||||
const columns: Array<Column<TableData>> = [ |
||||
{ id: 'id', header: 'ID' }, |
||||
{ id: 'value', header: 'Value', sortType: 'string' }, |
||||
{ id: 'country', header: 'Country', sortType: 'number' }, |
||||
]; |
||||
const data: TableData[] = [ |
||||
{ id: '1', value: '1', country: 'Sweden' }, |
||||
{ id: '2', value: '3', country: 'Portugal' }, |
||||
{ id: '3', value: '2', country: 'Italy' }, |
||||
]; |
||||
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />); |
||||
|
||||
const valueColumnHeader = screen.getByRole('columnheader', { name: 'Value' }); |
||||
const countryColumnHeader = screen.getByRole('columnheader', { name: 'Country' }); |
||||
const valueColumnSortButton = getByRole(valueColumnHeader, 'button'); |
||||
const countryColumnSortButton = getByRole(countryColumnHeader, 'button'); |
||||
|
||||
expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
|
||||
fireEvent.click(countryColumnSortButton); |
||||
expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
expect(countryColumnHeader).toHaveAttribute('aria-sort', 'ascending'); |
||||
|
||||
fireEvent.click(valueColumnSortButton); |
||||
expect(valueColumnHeader).toHaveAttribute('aria-sort', 'ascending'); |
||||
expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
|
||||
fireEvent.click(valueColumnSortButton); |
||||
expect(valueColumnHeader).toHaveAttribute('aria-sort', 'descending'); |
||||
expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
|
||||
fireEvent.click(valueColumnSortButton); |
||||
expect(valueColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
expect(countryColumnHeader).not.toHaveAttribute('aria-sort'); |
||||
}); |
||||
|
||||
it('correctly expands rows', () => { |
||||
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }]; |
||||
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; |
||||
render( |
||||
<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 }); |
||||
fireEvent.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
|
||||
screen.getByTestId('test-1').parentElement?.parentElement?.id |
||||
); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,29 @@ |
||||
import { ReactNode } from 'react'; |
||||
import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; |
||||
|
||||
export interface Column<TableData extends object> { |
||||
/** |
||||
* ID of the column. Must be unique among all other columns |
||||
*/ |
||||
id: IdType<TableData>; |
||||
/** |
||||
* Custom render function for te cell |
||||
*/ |
||||
cell?: (props: CellProps<TableData>) => ReactNode; |
||||
/** |
||||
* Header name. if `undefined` the header will be empty. Useful for action columns. |
||||
*/ |
||||
header?: string; |
||||
/** |
||||
* Column sort type. If `undefined` the column will not be sortable. |
||||
* */ |
||||
sortType?: DefaultSortTypes | SortByFn<TableData>; |
||||
/** |
||||
* If `true` prevents the column from growing more than its content. |
||||
*/ |
||||
disableGrow?: boolean; |
||||
/** |
||||
* If the provided function returns `false` the column will be hidden. |
||||
*/ |
||||
visible?: (data: TableData[]) => boolean; |
||||
} |
||||
@ -1,3 +0,0 @@ |
||||
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T; |
||||
|
||||
export const isTruthy = <T>(value: T): value is Truthy<T> => Boolean(value); |
||||
Loading…
Reference in new issue