From 16737c5b27842a75e2c5f903d42a1a695a58f0a4 Mon Sep 17 00:00:00 2001 From: Adam Bannach <113929542+abannachGrafana@users.noreply.github.com> Date: Fri, 22 Mar 2024 08:55:04 -0500 Subject: [PATCH] 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 --- packages/grafana-data/src/types/icon.ts | 2 + .../{ExpanderCell.tsx => Expander/index.tsx} | 19 +- .../InteractiveTable/InteractiveTable.mdx | 1 + .../InteractiveTable.story.tsx | 327 ++++++++---------- .../InteractiveTable.test.tsx | 109 +++++- .../InteractiveTable/InteractiveTable.tsx | 30 +- .../src/components/InteractiveTable/utils.ts | 10 +- .../img/icons/unicons/table-collapse-all.svg | 7 + public/img/icons/unicons/table-expand-all.svg | 7 + 9 files changed, 303 insertions(+), 209 deletions(-) rename packages/grafana-ui/src/components/InteractiveTable/{ExpanderCell.tsx => Expander/index.tsx} (55%) create mode 100644 public/img/icons/unicons/table-collapse-all.svg create mode 100644 public/img/icons/unicons/table-expand-all.svg diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 6b3a862a301..d79208807fe 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -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, diff --git a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx similarity index 55% rename from packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx rename to packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx index 03261f08dc0..9a57e38fc50 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx @@ -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({ row, __rowID }: CellProps ); } + +export function ExpanderHeader({ isAllRowsExpanded, toggleAllRowsExpanded }: HeaderProps) { + return ( +
+ toggleAllRowsExpanded()} + size={'lg'} + tooltip={!isAllRowsExpanded ? 'Expand all rows' : 'Collapse all rows'} + variant={'secondary'} + /> +
+ ); +} diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx index 4bde01ab917..c5c04a59cf6 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -111,6 +111,7 @@ export const MyComponent = () => { data={tableData} getRowId={(r) => r.datasource} renderExpandedRow={ExpandedCell} + showExpandAll /> ); }; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index 9d688b1d6d9..8993d20c2bd 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -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 = { - 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 = (args) => { - const columns = useMemo>>( - () => [ - { 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 r.header1} />; -}; - -interface WithRowExpansionData { - datasource: string; - repo: string; - description: string; -} - -const ExpandedCell = ({ description }: WithRowExpansionData) => { - return

{description}

; -}; - -export const WithRowExpansion: StoryFn = (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> = [ - { id: 'datasource', header: 'Data Source' }, - { id: 'repo', header: 'Repo' }, - ]; - - return ( - r.datasource} - renderExpandedRow={ExpandedCell} - /> - ); -}; - -interface WithCustomCellData { - datasource: string; - repo: string; -} - -const RepoCell = ({ - row: { - original: { repo }, - }, -}: CellProps) => { - return ( - - Open on GithHub - - ); -}; - -export const WithCustomCell: StoryFn = (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> = [ - { id: 'datasource', header: 'Data Source' }, - { id: 'repo', header: 'Repo', cell: RepoCell }, - ]; - - return 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 = (args) => { - const columns: Array> = [ - { id: 'firstName', header: 'First name' }, - { id: 'lastName', header: 'Last name' }, - { id: 'car', header: 'Car', sortType: 'string' }, - { id: 'age', header: 'Age', sortType: 'number' }, - ]; - return r.id} pageSize={15} />; +const meta: Meta> = { + 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 = (args) => { - const columns: Array> = [ - { 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>; - const headerTooltips: Record = { - age: { content: 'The number of years since the person was born' }, - lastName: { - content: () => { - return ( - <> -

Here is an h4

-
Some content
-
Some more content
- - ); +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

{car}

; +}; + +export const WithRowExpansion: TableStory = { + args: { + renderExpandedRow: ExpandedCell, + }, +}; + +interface WithCustomCellData { + datasource: string; + repo: string; +} + +const RepoCell = ({ + row: { + original: { repo }, + }, +}: CellProps) => { return ( - r.id} - headerTooltips={headerTooltips} - /> + + Open on GithHub + ); }; +export const WithCustomCell: StoryObj> = { + 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 = { + age: { content: 'The number of years since the person was born' }, + lastName: { + content: () => { + return ( + <> +

Here is an h4

+
Some content
+
Some more content
+ + ); + }, + iconName: 'plus-square', + }, +}; +export const WithHeaderTooltips: TableStory = { + args: { + headerTooltips, + }, +}; + 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) => { + const fetchData = useCallback(({ sortBy }: FetchDataArgs) => { if (!sortBy?.length) { return setData(pageableData); } @@ -291,9 +257,9 @@ export const WithControlledSort: StoryFn = (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]; + 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]; + const bData = b[sort.id as keyof Omit]; if (sort.desc) { return bData.localeCompare(aData); } @@ -303,6 +269,7 @@ export const WithControlledSort: StoryFn = (args) => { }, 300); }, []); - return r.id} pageSize={15} fetchData={fetchData} />; + return ; }; + 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 1e9418850aa..ec6c704f832 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -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> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + const { user } = setup( +
{row.country}
} + /> + ); - it('correctly expands rows', async () => { - const columns: Array> = [{ id: 'id', header: 'ID' }]; - const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; - const { user } = setup( -
{row.country}
} - /> - ); + 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> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + setup( +
{row.country}
} + 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> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + setup( +
{row.country}
} + /> + ); + + expect(screen.queryByRole('button', { name: 'Expand all rows' })).not.toBeInTheDocument(); + }); + it('renders expand all when showExpandAll is true', async () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + setup( +
{row.country}
} + showExpandAll + /> + ); + + expect(screen.getByRole('button', { name: 'Expand all rows' })).toBeInTheDocument(); + }); + it('expands all rows when expand all is clicked', async () => { + const columns: Array> = [{ 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( +
{row.country}
} + 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', () => { diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index cdd8df052b8..c66a1fe7439 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -120,7 +120,8 @@ export type InteractiveTableHeaderTooltip = { export type FetchDataArgs = { sortBy: Array> }; export type FetchDataFunc = ({ sortBy }: FetchDataArgs) => void; -interface Props { + +interface BaseProps { className?: string; /** * Table's columns definition. Must be memoized. @@ -142,10 +143,6 @@ interface Props { * 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 { fetchData?: FetchDataFunc; } +interface WithExpandableRow extends BaseProps { + /** + * 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 extends BaseProps { + renderExpandedRow?: never; + showExpandAll?: never; +} + +type Props = WithExpandableRow | WithoutExpandableRow; + /** @alpha */ export function InteractiveTable({ className, @@ -163,12 +178,13 @@ export function InteractiveTable({ headerTooltips, pageSize = 0, renderExpandedRow, + showExpandAll = false, fetchData, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { - return getColumns(columns); - }, [columns]); + return getColumns(columns, showExpandAll); + }, [columns, showExpandAll]); const id = useUniqueId(); const getRowHTMLID = useCallback( (row: Row) => { diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 7eb970008b1..68017bbfd19 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -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 = RTColumn & { }; // Returns the columns in a "react-table" acceptable format -export function getColumns(columns: Array>): Array> { +export function getColumns( + columns: Array>, + showExpandAll = false +): Array> { return [ { id: EXPANDER_CELL_ID, Cell: ExpanderCell, + ...(showExpandAll && { + Header: ExpanderHeader, + }), disableSortBy: true, width: 0, }, diff --git a/public/img/icons/unicons/table-collapse-all.svg b/public/img/icons/unicons/table-collapse-all.svg new file mode 100644 index 00000000000..bacf4ac77a9 --- /dev/null +++ b/public/img/icons/unicons/table-collapse-all.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/public/img/icons/unicons/table-expand-all.svg b/public/img/icons/unicons/table-expand-all.svg new file mode 100644 index 00000000000..06682bc0605 --- /dev/null +++ b/public/img/icons/unicons/table-expand-all.svg @@ -0,0 +1,7 @@ + + + + +