From 0bbf011ca80f96651f88536c792484826a7c2d57 Mon Sep 17 00:00:00 2001 From: abannachGrafana <113929542+abannachGrafana@users.noreply.github.com> Date: Thu, 29 Jun 2023 01:22:22 -0500 Subject: [PATCH] InteractiveTable: Add pagination and header tooltips (#70281) * feat(interactiveTable): add pagination and header tooltips * docs: add note about client side pagination --- .../InteractiveTable/InteractiveTable.mdx | 107 +++++++++ .../InteractiveTable.story.tsx | 136 +++++++++++ .../InteractiveTable.test.tsx | 47 +++- .../InteractiveTable/InteractiveTable.tsx | 216 ++++++++++++------ 4 files changed, 432 insertions(+), 74 deletions(-) diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx index e33576294f3..095db2b5376 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -164,3 +164,110 @@ export const MyComponent = () => { return r.datasource} />; }; ``` + +### With pagination + +The table can be rendered with pagination controls by passing in the `pageSize` property. All data must be provided as +only client side pagination is supported. + + + +```tsx +interface WithPaginationData { + id: string; + firstName: string; + lastName: string; + car: string; + age: number; +} + +export const MyComponent = () => { + const pageableData: WithPaginationData[] = [ + { id: '48a3926a-e82c-4c26-b959-3a5f473e186e', firstName: 'Brynne', lastName: 'Denisevich', car: 'Cougar', age: 47 }, + { + id: 'cf281390-adbf-4407-8cf3-a52e012f63e6', + firstName: 'Aldridge', + lastName: 'Shirer', + car: 'Viper RT/10', + age: 74, + }, + // ... + { + id: 'b9b0b559-acc1-4bd8-b052-160ecf3e4f68', + firstName: 'Ermanno', + lastName: 'Sinott', + car: 'Thunderbird', + age: 26, + }, + ]; + 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} />; +}; +``` + +### With header tooltips + +It may be useful to render a tooltip on the header of a column to provide additional information about the data in that column. + + + +```tsx +interface WithPaginationData { + id: string; + firstName: string; + lastName: string; + car: string; + age: number; +} + +export const MyComponent = () => { + const pageableData: WithPaginationData[] = [ + { id: '48a3926a-e82c-4c26-b959-3a5f473e186e', firstName: 'Brynne', lastName: 'Denisevich', car: 'Cougar', age: 47 }, + { + id: 'cf281390-adbf-4407-8cf3-a52e012f63e6', + firstName: 'Aldridge', + lastName: 'Shirer', + car: 'Viper RT/10', + age: 74, + }, + // ... + { + id: 'b9b0b559-acc1-4bd8-b052-160ecf3e4f68', + firstName: 'Ermanno', + lastName: 'Sinott', + car: 'Thunderbird', + age: 26, + }, + ]; + 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' }, + ]; + + const headerToolTips = { + 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', + }, + }; + return ( + r.id} headerToolTips={headerToolTips} /> + ); +}; +``` diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index b0a1447cc6d..7911b18e520 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -5,6 +5,7 @@ import { InteractiveTable, Column, CellProps, LinkButton } from '@grafana/ui'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { InteractiveTableHeaderTooltip } from './InteractiveTable'; import mdx from './InteractiveTable.mdx'; const EXCLUDED_PROPS = ['className', 'renderExpandedRow', 'getRowId']; @@ -139,4 +140,139 @@ export const WithCustomCell: StoryFn = (args) => { return r.datasource} />; }; +interface WithPaginationData { + id: string; + firstName: string; + lastName: string; + car: string; + age: number; +} + +const pageableData: WithPaginationData[] = [ + { id: '48a3926a-e82c-4c26-b959-3a5f473e186e', firstName: 'Brynne', lastName: 'Denisevich', car: 'Cougar', age: 47 }, + { + id: 'cf281390-adbf-4407-8cf3-a52e012f63e6', + firstName: 'Aldridge', + lastName: 'Shirer', + car: 'Viper RT/10', + age: 74, + }, + { id: 'be5736f5-7015-4668-a03d-44b56f2b012c', firstName: 'Sonni', lastName: 'Hinrich', car: 'Ramcharger', 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', car: 'TSX', 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 }, + { id: '34badca2-895f-4dff-bd34-74c1edd5f309', firstName: 'Ad', lastName: 'Beare', car: 'Freestyle', age: 58 }, + { + id: '8676e97d-b19f-4a98-bbb4-a48c3673c216', + firstName: 'Tally', + lastName: 'Prestie', + car: 'Montero Sport', + age: 91, + }, + { id: '12ea99c6-ccd9-4313-af92-df9141b3d4bd', firstName: 'Wendel', lastName: 'Chasles', car: 'Corvette', age: 89 }, + { id: 'a153ad38-d9b7-4437-a8ac-c1198f0060ef', firstName: 'Lester', lastName: 'Klewer', car: 'Xterra', age: 21 }, + { id: 'ead42cd5-dcd9-4886-879a-fce2eacb4c2b', firstName: 'Ferd', lastName: 'Pasterfield', car: 'Tiburon', age: 1 }, + { id: '97410315-a0a5-4488-8c91-ba7ff640dd9b', firstName: 'Alphonse', lastName: 'Espinola', car: 'Laser', age: 30 }, + { id: 'e4d93eab-ca85-47cc-9867-06aeb29951e3', firstName: 'Dorry', lastName: 'Attew', car: 'Tahoe', age: 90 }, + { id: 'f0047d6f-f517-4f9d-99c2-ce15dcd6a78a', firstName: 'Zed', lastName: 'McMinn', car: '745', age: 96 }, + { id: '5ac3fac4-7caa-4f8e-8fde-115c4a0eca85', firstName: 'Fredericka', lastName: 'Hains', car: 'A6', age: 39 }, + { id: '03ffcc41-4a03-46f5-a161-431d331293dd', firstName: 'Syd', lastName: 'Brixey', car: 'Camry Hybrid', age: 70 }, + { id: '7086f360-f19d-4b0c-9bce-48b2784f200a', firstName: 'Casey', lastName: 'Margerrison', car: 'NV3500', age: 38 }, + { + id: '8375ab44-0c61-4987-8154-02d1b2fd12a7', + firstName: 'Sallyann', + lastName: 'Northleigh', + car: 'Tiburon', + age: 51, + }, + { id: '3af1e7cc-92c9-4356-85eb-bdcecbdffcda', firstName: 'Yance', lastName: 'Nani', car: 'F350', age: 21 }, + { id: '46cf82f7-d9be-4a1d-b7cc-fc15133353dc', firstName: 'Judas', lastName: 'Riach', car: 'RSX', age: 31 }, + { id: '0d10f9cd-78b9-4584-bc01-a35bcae0a14a', firstName: 'Mikkel', lastName: 'Dellenbrok', car: 'VUE', age: 53 }, + { id: '1a78e628-6b8b-4d6a-b391-bbfa650b8024', firstName: 'Son', lastName: 'Vaudin', car: 'Sunbird', age: 47 }, + { id: 'd1349bf6-6dd1-4aed-9788-84e8b642ad63', firstName: 'Emilio', lastName: 'Liddington', car: 'F250', age: 2 }, + { id: '14a3a8e8-15d7-469e-87c6-85181e22b3b8', firstName: 'Devin', lastName: 'Meadley', car: 'XT', age: 61 }, + { id: '47cccba7-9f9b-44f5-985c-c2e226b2c9e4', firstName: 'Harriott', lastName: 'Seres', car: 'LeSabre', age: 11 }, + { id: 'e668a9b1-1dcd-4b5d-9d4e-479dc08695d6', firstName: 'Elvin', lastName: 'Diable', car: '90', age: 69 }, + { id: 'addf8ee9-934c-4e81-83e8-20f50bbff028', firstName: 'Rey', lastName: 'Scotford', car: 'H1', age: 71 }, + { id: 'f22dbd3f-8419-4a1c-b542-23c3842cb59b', firstName: 'King', lastName: 'Catonne', car: 'Suburban 2500', age: 91 }, + { id: 'c85b7547-3654-41f0-94d6-becc832b81fa', firstName: 'Barbabas', lastName: 'Romeril', car: 'Sorento', age: 5 }, + { id: '8d83b0eb-635d-452e-9f85-f19216207ad1', firstName: 'Hadley', lastName: 'Bartoletti', car: 'Seville', age: 37 }, + { id: '9bdb532a-c747-4288-b2e9-e3f2dc7e0a15', firstName: 'Willie', lastName: 'Dunkerley', car: 'Envoy', age: 34 }, + { id: '6b4413dd-1f77-4504-86ee-1ea5b90c6279', firstName: 'Annamarie', lastName: 'Burras', car: 'Elantra', age: 12 }, + { id: 'f17a5f2a-92a9-48a9-a05c-a3c44c66adb7', firstName: 'Rebecca', lastName: 'Thomason', car: 'Elantra', age: 6 }, + { id: '85f7d4d2-3ae6-42ab-88dd-d4e810ebb76c', firstName: 'Tatum', lastName: 'Monte', car: 'Achieva', age: 53 }, + { id: '3d374982-6cd9-4e6e-abf1-7de38eee4b68', firstName: 'Tallie', lastName: 'Goodlet', car: 'Integra', age: 81 }, + { id: 'ccded1ef-f648-4970-ae6e-882ba4d789fb', firstName: 'Catrina', lastName: 'Thunderman', car: 'RX', age: 91 }, + { id: '3198513a-b05f-4d0d-8187-214f82f88531', firstName: 'Aldric', lastName: 'Awton', car: 'Swift', age: 78 }, + { id: '35c3d0ce-52ea-4f30-8c17-b1e6b9878aa3', firstName: 'Garry', lastName: 'Ineson', car: 'Discovery', age: 25 }, + { id: 'c5ae799a-983f-4933-8a4d-cda754acedc0', firstName: 'Alica', lastName: 'Rubinfeld', car: 'FX', age: 20 }, + { id: 'cd9e5476-1ebb-46f0-926e-cee522e8d332', firstName: 'Wenonah', lastName: 'Blakey', car: 'Cooper', age: 96 }, + { id: '17449829-4a8f-433c-8cb0-a869f153ea34', firstName: 'Bevon', lastName: 'Cushe', car: 'GTI', age: 23 }, + { id: 'd20d41a3-d9fe-492d-91df-51a962c515b9', firstName: 'Marybeth', lastName: 'Gauson', car: 'MR2', age: 53 }, + { + id: 'cd046551-5df7-44b5-88b3-d1654a838214', + firstName: 'Kimball', + lastName: 'Bellhanger', + car: 'Ram 1500', + age: 56, + }, + { + id: 'a8114bdf-911d-410f-b90b-4c8a9c302743', + firstName: 'Cindelyn', + lastName: 'Beamont', + car: 'Monte Carlo', + age: 99, + }, + { id: 'e31709ba-bf65-42d1-8c5c-60d461bc3e75', firstName: 'Elfreda', lastName: 'Riddles', car: 'Montero', age: 59 }, + { id: 'cd67179c-0c49-486d-baa9-8e956b362c2e', firstName: 'Chickie', lastName: 'Picheford', car: 'Legend', age: 56 }, + { 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} />; +}; + +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' }, + ]; + + 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', + }, + }; + + return ( + r.id} + headerTooltips={headerTooltips} + /> + ); +}; + 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 579b5e5793d..d0d52f3eaca 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, getByRole, render, screen } from '@testing-library/react'; +import { fireEvent, getByRole, render, screen, cleanup } from '@testing-library/react'; import React from 'react'; import { InteractiveTable } from './InteractiveTable'; @@ -92,4 +92,49 @@ describe('InteractiveTable', () => { screen.getByTestId('test-1').parentElement?.parentElement?.id ); }); + describe('pagination', () => { + it('does not render pagination controls if pageSize is not set', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.queryByRole('button', { name: /next/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + + cleanup(); + + render(); + + expect(screen.queryByRole('button', { name: /next/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + }); + + it('renders pagination controls if pageSize is set', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); + }); + }); + describe('headerTooltip', () => { + it('does not render tooltips if headerTooltips is not set', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.queryByTestId('header-tooltip-icon')).not.toBeInTheDocument(); + }); + it('renders tooltips if headerTooltips is set', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + const headerTooltips = { + id: { content: 'this is the id' }, + }; + render(); + + expect(screen.getByTestId('header-tooltip-icon')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index e556c4388fd..10a4b648781 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -1,12 +1,23 @@ -import { cx, css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { uniqueId } from 'lodash'; -import React, { useMemo, Fragment, ReactNode, useCallback } from 'react'; -import { useExpanded, useSortBy, useTable, TableOptions, Row, HeaderGroup } from 'react-table'; +import React, { Fragment, ReactNode, useCallback, useEffect, useMemo } from 'react'; +import { + HeaderGroup, + PluginHook, + Row, + TableOptions, + useExpanded, + usePagination, + useSortBy, + useTable, +} from 'react-table'; -import { GrafanaTheme2, isTruthy } from '@grafana/data'; +import { GrafanaTheme2, IconName, isTruthy } from '@grafana/data'; import { useStyles2 } from '../../themes'; import { Icon } from '../Icon/Icon'; +import { Pagination } from '../Pagination/Pagination'; +import { PopoverContent, Tooltip } from '../Tooltip'; import { Column } from './types'; import { EXPANDER_CELL_ID, getColumns } from './utils'; @@ -15,6 +26,12 @@ const getStyles = (theme: GrafanaTheme2) => { const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03); return { + container: css` + display: flex; + gap: ${theme.spacing(2)}; + flex-direction: column; + width: 100%; + `, table: css` border-radius: ${theme.shape.borderRadius()}; width: 100%; @@ -29,7 +46,7 @@ const getStyles = (theme: GrafanaTheme2) => { } `, disableGrow: css` - width: 0%; + width: 0; `, header: css` border-bottom: 1px solid ${theme.colors.border.weak}; @@ -96,7 +113,13 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; +export type InteractiveTableHeaderTooltip = { + content: PopoverContent; + iconName?: IconName; +}; + interface Props { + className?: string; /** * Table's columns definition. Must be memoized. */ @@ -105,29 +128,37 @@ interface Props { * The data to display in the table. Must be memoized. */ data: TableData[]; - /** - * Render function for the expanded row. if not provided, the tables rows will not be expandable. - */ - renderExpandedRow?: (row: TableData) => ReactNode; - className?: string; /** * Must return a unique id for each row */ getRowId: TableOptions['getRowId']; + /** + * Optional tooltips for the table headers. The key must match the column id. + */ + headerTooltips?: Record; + /** + * 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; } /** @alpha */ export function InteractiveTable({ - data, className, columns, - renderExpandedRow, + data, getRowId, + headerTooltips, + pageSize = 0, + renderExpandedRow, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { - const cols = getColumns(columns); - return cols; + return getColumns(columns); }, [columns]); const id = useUniqueId(); const getRowHTMLID = useCallback( @@ -137,7 +168,15 @@ export function InteractiveTable({ [id] ); - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable( + const tableHooks: Array> = [useSortBy, useExpanded]; + + const paginationEnabled = pageSize > 0; + + if (paginationEnabled) { + tableHooks.push(usePagination); + } + + const tableInstance = useTable( { columns: tableColumns, data, @@ -155,73 +194,91 @@ export function InteractiveTable({ ].filter(isTruthy), }, }, - useSortBy, - useExpanded + ...tableHooks ); - // This should be called only for rows thar we'd want to actually render, which is all at this stage. - // We may want to revisit this if we decide to add pagination and/or virtualized tables. - rows.forEach(prepareRow); + const { getTableProps, getTableBodyProps, headerGroups, prepareRow } = tableInstance; + + useEffect(() => { + if (paginationEnabled) { + tableInstance.setPageSize(pageSize); + } + }, [paginationEnabled, pageSize, tableInstance.setPageSize, tableInstance]); return ( - - - {headerGroups.map((headerGroup) => { - const { key, ...headerRowProps } = headerGroup.getHeaderGroupProps(); +
+
+ + {headerGroups.map((headerGroup) => { + const { key, ...headerRowProps } = headerGroup.getHeaderGroupProps(); - return ( - - {headerGroup.headers.map((column) => { - const { key, ...headerCellProps } = column.getHeaderProps(); + return ( + + {headerGroup.headers.map((column) => { + const { key, ...headerCellProps } = column.getHeaderProps(); - return ( - - ); - })} - - ); - })} - + const headerTooltip = headerTooltips?.[column.id]; - - {rows.map((row) => { - const { key, ...otherRowProps } = row.getRowProps(); - const rowId = getRowHTMLID(row); - // @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz - const isExpanded = row.isExpanded; - - return ( - - - {row.cells.map((cell) => { - const { key, ...otherCellProps } = cell.getCellProps(); return ( - + ); })} - {isExpanded && renderExpandedRow && ( - - + ); + })} + + + + {(paginationEnabled ? tableInstance.page : tableInstance.rows).map((row) => { + prepareRow(row); + + const { key, ...otherRowProps } = row.getRowProps(); + const rowId = getRowHTMLID(row); + // @ts-expect-error react-table doesn't ship with useExpanded types, and we can't use declaration merging without affecting the table viz + const isExpanded = row.isExpanded; + + return ( + + + {row.cells.map((cell) => { + const { key, ...otherCellProps } = cell.getCellProps(); + return ( + + ); + })} - )} - - ); - })} - -
- -
- {cell.render('Cell', { __rowID: rowId })} - + +
{renderExpandedRow(row.original)}
+ {cell.render('Cell', { __rowID: rowId })} +
+ {isExpanded && renderExpandedRow && ( + + {renderExpandedRow(row.original)} + + )} + + ); + })} + + + {paginationEnabled && ( + + tableInstance.gotoPage(toPage - 1)} + /> + + )} + ); } @@ -229,25 +286,38 @@ const useUniqueId = () => { return useMemo(() => uniqueId('InteractiveTable'), []); }; -const getColumnheaderStyles = (theme: GrafanaTheme2) => ({ +const getColumnHeaderStyles = (theme: GrafanaTheme2) => ({ sortIcon: css` position: absolute; top: ${theme.spacing(1)}; `, + headerTooltipIcon: css` + margin-left: ${theme.spacing(0.5)}; + `, }); function ColumnHeader({ column: { canSort, render, isSorted, isSortedDesc, getSortByToggleProps }, + headerTooltip, }: { column: HeaderGroup; + headerTooltip?: InteractiveTableHeaderTooltip; }) { - const styles = useStyles2(getColumnheaderStyles); + const styles = useStyles2(getColumnHeaderStyles); const { onClick } = getSortByToggleProps(); const children = ( <> {render('Header')} - + {headerTooltip && ( + + + + )} {isSorted && (