mirror of https://github.com/grafana/grafana.git
				
				
				
			Grafana UI: Table - add sticky table footer (#38094)
* table footer to allow showing summary data from the table panel
This commit is contained in:
		
							parent
							
								
									681de1ea89
								
							
						
					
					
						commit
						4f479de88e
					
				|  | @ -78,6 +78,7 @@ export const Components = { | |||
|       }, | ||||
|       Table: { | ||||
|         header: 'table header', | ||||
|         footer: 'table-footer', | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|  |  | |||
|  | @ -0,0 +1,46 @@ | |||
| import React from 'react'; | ||||
| import { FooterItem } from './types'; | ||||
| import { KeyValue } from '@grafana/data'; | ||||
| import { css } from '@emotion/css'; | ||||
| 
 | ||||
| export interface FooterProps { | ||||
|   value: FooterItem; | ||||
| } | ||||
| 
 | ||||
| export const FooterCell = (props: FooterProps) => { | ||||
|   const cell = css` | ||||
|     width: 100%; | ||||
|     list-style: none; | ||||
|   `;
 | ||||
| 
 | ||||
|   const list = css` | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
|   `;
 | ||||
| 
 | ||||
|   if (props.value && !Array.isArray(props.value)) { | ||||
|     return <span>{props.value}</span>; | ||||
|   } | ||||
|   if (props.value && Array.isArray(props.value) && props.value.length > 0) { | ||||
|     return ( | ||||
|       <ul className={cell}> | ||||
|         {props.value.map((v: KeyValue<string>, i) => { | ||||
|           const key = Object.keys(v)[0]; | ||||
|           return ( | ||||
|             <li className={list} key={i}> | ||||
|               <span>{key}:</span> | ||||
|               <span>{v[key]}</span> | ||||
|             </li> | ||||
|           ); | ||||
|         })} | ||||
|       </ul> | ||||
|     ); | ||||
|   } | ||||
|   return EmptyCell; | ||||
| }; | ||||
| 
 | ||||
| export const EmptyCell = (props: any) => { | ||||
|   return <span> </span>; | ||||
| }; | ||||
|  | @ -0,0 +1,94 @@ | |||
| import React from 'react'; | ||||
| import { ColumnInstance, HeaderGroup } from 'react-table'; | ||||
| import { selectors } from '@grafana/e2e-selectors'; | ||||
| import { getTableStyles, TableStyles } from './styles'; | ||||
| import { useStyles2 } from '../../themes'; | ||||
| import { FooterItem } from './types'; | ||||
| import { EmptyCell, FooterCell } from './FooterCell'; | ||||
| 
 | ||||
| export interface FooterRowProps { | ||||
|   totalColumnsWidth: number; | ||||
|   footerGroups: HeaderGroup[]; | ||||
|   footerValues?: FooterItem[]; | ||||
| } | ||||
| 
 | ||||
| export const FooterRow = (props: FooterRowProps) => { | ||||
|   const { totalColumnsWidth, footerGroups, footerValues } = props; | ||||
|   const e2eSelectorsTable = selectors.components.Panels.Visualization.Table; | ||||
|   const tableStyles = useStyles2(getTableStyles); | ||||
|   const EXTENDED_ROW_HEIGHT = 27; | ||||
| 
 | ||||
|   if (!footerValues) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   let length = 0; | ||||
|   for (const fv of footerValues) { | ||||
|     if (Array.isArray(fv) && fv.length > length) { | ||||
|       length = fv.length; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   let height: number | undefined; | ||||
|   if (footerValues && length > 1) { | ||||
|     height = EXTENDED_ROW_HEIGHT * length; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <table | ||||
|       style={{ | ||||
|         position: 'absolute', | ||||
|         width: totalColumnsWidth ? `${totalColumnsWidth}px` : '100%', | ||||
|         bottom: '0px', | ||||
|       }} | ||||
|     > | ||||
|       {footerGroups.map((footerGroup: HeaderGroup) => { | ||||
|         const { key, ...footerGroupProps } = footerGroup.getFooterGroupProps(); | ||||
|         return ( | ||||
|           <tfoot | ||||
|             className={tableStyles.tfoot} | ||||
|             {...footerGroupProps} | ||||
|             key={key} | ||||
|             data-testid={e2eSelectorsTable.footer} | ||||
|             style={height ? { height: `${height}px` } : undefined} | ||||
|           > | ||||
|             <tr> | ||||
|               {footerGroup.headers.map((column: ColumnInstance, index: number) => | ||||
|                 renderFooterCell(column, tableStyles, height) | ||||
|               )} | ||||
|             </tr> | ||||
|           </tfoot> | ||||
|         ); | ||||
|       })} | ||||
|     </table> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| function renderFooterCell(column: ColumnInstance, tableStyles: TableStyles, height?: number) { | ||||
|   const footerProps = column.getHeaderProps(); | ||||
| 
 | ||||
|   if (!footerProps) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   footerProps.style = footerProps.style ?? {}; | ||||
|   footerProps.style.position = 'absolute'; | ||||
|   footerProps.style.justifyContent = (column as any).justifyContent; | ||||
|   if (height) { | ||||
|     footerProps.style.height = height; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <th className={tableStyles.headerCell} {...footerProps}> | ||||
|       {column.render('Footer')} | ||||
|     </th> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function getFooterValue(index: number, footerValues?: FooterItem[]) { | ||||
|   if (footerValues === undefined) { | ||||
|     return EmptyCell; | ||||
|   } | ||||
| 
 | ||||
|   return FooterCell({ value: footerValues[index] }); | ||||
| } | ||||
|  | @ -0,0 +1,74 @@ | |||
| import React from 'react'; | ||||
| import { HeaderGroup, Column } from 'react-table'; | ||||
| import { DataFrame, Field } from '@grafana/data'; | ||||
| import { selectors } from '@grafana/e2e-selectors'; | ||||
| import { getTableStyles, TableStyles } from './styles'; | ||||
| import { useStyles2 } from '../../themes'; | ||||
| import { Filter } from './Filter'; | ||||
| import { Icon } from '../Icon/Icon'; | ||||
| 
 | ||||
| export interface HeaderRowProps { | ||||
|   headerGroups: HeaderGroup[]; | ||||
|   data: DataFrame; | ||||
| } | ||||
| 
 | ||||
| export const HeaderRow = (props: HeaderRowProps) => { | ||||
|   const { headerGroups, data } = props; | ||||
|   const e2eSelectorsTable = selectors.components.Panels.Visualization.Table; | ||||
|   const tableStyles = useStyles2(getTableStyles); | ||||
| 
 | ||||
|   return ( | ||||
|     <div role="rowgroup"> | ||||
|       {headerGroups.map((headerGroup: HeaderGroup) => { | ||||
|         const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); | ||||
|         return ( | ||||
|           <div | ||||
|             className={tableStyles.thead} | ||||
|             {...headerGroupProps} | ||||
|             key={key} | ||||
|             aria-label={e2eSelectorsTable.header} | ||||
|             role="row" | ||||
|           > | ||||
|             {headerGroup.headers.map((column: Column, index: number) => | ||||
|               renderHeaderCell(column, tableStyles, data.fields[index]) | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       })} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) { | ||||
|   const headerProps = column.getHeaderProps(); | ||||
| 
 | ||||
|   if (column.canResize) { | ||||
|     headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
 | ||||
|   } | ||||
| 
 | ||||
|   headerProps.style.position = 'absolute'; | ||||
|   headerProps.style.justifyContent = (column as any).justifyContent; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={tableStyles.headerCell} {...headerProps} role="columnheader"> | ||||
|       {column.canSort && ( | ||||
|         <> | ||||
|           <div | ||||
|             {...column.getSortByToggleProps()} | ||||
|             className={tableStyles.headerCellLabel} | ||||
|             title={column.render('Header')} | ||||
|           > | ||||
|             <div>{column.render('Header')}</div> | ||||
|             <div> | ||||
|               {column.isSorted && (column.isSortedDesc ? <Icon name="arrow-down" /> : <Icon name="arrow-up" />)} | ||||
|             </div> | ||||
|           </div> | ||||
|           {column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} | ||||
|         </> | ||||
|       )} | ||||
|       {!column.canSort && column.render('Header')} | ||||
|       {!column.canSort && column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} | ||||
|       {column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | @ -13,8 +13,10 @@ import { | |||
|   ThresholdsConfig, | ||||
|   ThresholdsMode, | ||||
|   FieldConfig, | ||||
|   formattedValueToString, | ||||
| } from '@grafana/data'; | ||||
| import { prepDataForStorybook } from '../../utils/storybook/data'; | ||||
| import { FooterItem } from './types'; | ||||
| 
 | ||||
| export default { | ||||
|   title: 'Visualizations/Table', | ||||
|  | @ -93,6 +95,23 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D | |||
|   return prepDataForStorybook([data], theme)[0]; | ||||
| } | ||||
| 
 | ||||
| function buildFooterData(data: DataFrame): FooterItem[] { | ||||
|   const values = data.fields[3].values.toArray(); | ||||
|   const valueSum = values.reduce((prev, curr) => { | ||||
|     return prev + curr; | ||||
|   }, 0); | ||||
| 
 | ||||
|   const valueField = data.fields[3]; | ||||
|   const displayValue = valueField.display ? valueField.display(valueSum) : valueSum; | ||||
|   const val = valueField.display ? formattedValueToString(displayValue) : displayValue; | ||||
| 
 | ||||
|   const sum = { sum: val }; | ||||
|   const min = { min: String(5.2) }; | ||||
|   const valCell = [sum, min]; | ||||
| 
 | ||||
|   return ['Totals', '10', undefined, valCell, '100%']; | ||||
| } | ||||
| 
 | ||||
| const defaultThresholds: ThresholdsConfig = { | ||||
|   steps: [ | ||||
|     { | ||||
|  | @ -155,3 +174,15 @@ export const ColoredCells: Story = (args) => { | |||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const Footer: Story = (args) => { | ||||
|   const theme = useTheme2(); | ||||
|   const data = buildData(theme, {}); | ||||
|   const footer = buildFooterData(data); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="panel-container" style={{ width: 'auto', height: 'unset' }}> | ||||
|       <Table data={data} height={args.height} width={args.width} footerValues={footer} {...args} /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -91,7 +91,11 @@ function getTestContext(propOverrides: Partial<Props> = {}) { | |||
| } | ||||
| 
 | ||||
| function getTable(): HTMLElement { | ||||
|   return screen.getByRole('table'); | ||||
|   return screen.getAllByRole('table')[0]; | ||||
| } | ||||
| 
 | ||||
| function getFooter(): HTMLElement { | ||||
|   return screen.getByTestId('table-footer'); | ||||
| } | ||||
| 
 | ||||
| function getColumnHeader(name: string | RegExp): HTMLElement { | ||||
|  | @ -142,6 +146,15 @@ describe('Table', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when mounted with footer', () => { | ||||
|     it('then footer should be displayed', () => { | ||||
|       const footerValues = ['a', 'b', 'c']; | ||||
|       getTestContext({ footerValues }); | ||||
|       expect(getTable()).toBeInTheDocument(); | ||||
|       expect(getFooter()).toBeInTheDocument(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when sorting with column header', () => { | ||||
|     it('then correct rows should be rendered', () => { | ||||
|       getTestContext(); | ||||
|  |  | |||
|  | @ -1,9 +1,8 @@ | |||
| import React, { FC, memo, useCallback, useMemo } from 'react'; | ||||
| import { DataFrame, Field, getFieldDisplayName } from '@grafana/data'; | ||||
| import { DataFrame, getFieldDisplayName } from '@grafana/data'; | ||||
| import { | ||||
|   Cell, | ||||
|   Column, | ||||
|   HeaderGroup, | ||||
|   useAbsoluteLayout, | ||||
|   useFilters, | ||||
|   UseFiltersState, | ||||
|  | @ -18,19 +17,18 @@ import { getColumns, sortCaseInsensitive, sortNumber } from './utils'; | |||
| import { | ||||
|   TableColumnResizeActionCallback, | ||||
|   TableFilterActionCallback, | ||||
|   FooterItem, | ||||
|   TableSortByActionCallback, | ||||
|   TableSortByFieldState, | ||||
| } from './types'; | ||||
| import { getTableStyles, TableStyles } from './styles'; | ||||
| import { Icon } from '../Icon/Icon'; | ||||
| import { getTableStyles } from './styles'; | ||||
| import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; | ||||
| import { Filter } from './Filter'; | ||||
| import { TableCell } from './TableCell'; | ||||
| import { useStyles2 } from '../../themes'; | ||||
| import { selectors } from '@grafana/e2e-selectors'; | ||||
| import { FooterRow } from './FooterRow'; | ||||
| import { HeaderRow } from './HeaderRow'; | ||||
| 
 | ||||
| const COLUMN_MIN_WIDTH = 150; | ||||
| const e2eSelectorsTable = selectors.components.Panels.Visualization.Table; | ||||
| 
 | ||||
| export interface Props { | ||||
|   ariaLabel?: string; | ||||
|  | @ -45,6 +43,7 @@ export interface Props { | |||
|   onColumnResize?: TableColumnResizeActionCallback; | ||||
|   onSortByChange?: TableSortByActionCallback; | ||||
|   onCellFilterAdded?: TableFilterActionCallback; | ||||
|   footerValues?: FooterItem[]; | ||||
| } | ||||
| 
 | ||||
| interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}>, UseFiltersState<{}> {} | ||||
|  | @ -124,6 +123,7 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
|     noHeader, | ||||
|     resizable = true, | ||||
|     initialSortBy, | ||||
|     footerValues, | ||||
|   } = props; | ||||
|   const tableStyles = useStyles2(getTableStyles); | ||||
| 
 | ||||
|  | @ -140,7 +140,12 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
|   }, [data]); | ||||
| 
 | ||||
|   // React-table column definitions
 | ||||
|   const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]); | ||||
|   const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth, footerValues), [ | ||||
|     data, | ||||
|     width, | ||||
|     columnMinWidth, | ||||
|     footerValues, | ||||
|   ]); | ||||
| 
 | ||||
|   // Internal react table state reducer
 | ||||
|   const stateReducer = useTableStateReducer(props); | ||||
|  | @ -160,7 +165,7 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
|     [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer] | ||||
|   ); | ||||
| 
 | ||||
|   const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable( | ||||
|   const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth, footerGroups } = useTable( | ||||
|     options, | ||||
|     useFilters, | ||||
|     useSortBy, | ||||
|  | @ -196,28 +201,10 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
|   const headerHeight = noHeader ? 0 : tableStyles.cellHeight; | ||||
| 
 | ||||
|   return ( | ||||
|     <div {...getTableProps()} className={tableStyles.table} aria-label={ariaLabel}> | ||||
|     <div {...getTableProps()} className={tableStyles.table} aria-label={ariaLabel} role="table"> | ||||
|       <CustomScrollbar hideVerticalTrack={true}> | ||||
|         <div style={{ width: totalColumnsWidth ? `${totalColumnsWidth}px` : '100%' }}> | ||||
|           {!noHeader && ( | ||||
|             <div> | ||||
|               {headerGroups.map((headerGroup: HeaderGroup) => { | ||||
|                 const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); | ||||
|                 return ( | ||||
|                   <div | ||||
|                     className={tableStyles.thead} | ||||
|                     {...headerGroupProps} | ||||
|                     key={key} | ||||
|                     aria-label={e2eSelectorsTable.header} | ||||
|                   > | ||||
|                     {headerGroup.headers.map((column: Column, index: number) => | ||||
|                       renderHeaderCell(column, tableStyles, data.fields[index]) | ||||
|                     )} | ||||
|                   </div> | ||||
|                 ); | ||||
|               })} | ||||
|             </div> | ||||
|           )} | ||||
|           {!noHeader && <HeaderRow data={data} headerGroups={headerGroups} />} | ||||
|           {rows.length > 0 ? ( | ||||
|             <FixedSizeList | ||||
|               height={height - headerHeight} | ||||
|  | @ -233,6 +220,7 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
|               No data | ||||
|             </div> | ||||
|           )} | ||||
|           <FooterRow footerValues={footerValues} footerGroups={footerGroups} totalColumnsWidth={totalColumnsWidth} /> | ||||
|         </div> | ||||
|       </CustomScrollbar> | ||||
|     </div> | ||||
|  | @ -240,37 +228,3 @@ export const Table: FC<Props> = memo((props: Props) => { | |||
| }); | ||||
| 
 | ||||
| Table.displayName = 'Table'; | ||||
| 
 | ||||
| function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) { | ||||
|   const headerProps = column.getHeaderProps(); | ||||
| 
 | ||||
|   if (column.canResize) { | ||||
|     headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
 | ||||
|   } | ||||
| 
 | ||||
|   headerProps.style.position = 'absolute'; | ||||
|   headerProps.style.justifyContent = (column as any).justifyContent; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={tableStyles.headerCell} {...headerProps}> | ||||
|       {column.canSort && ( | ||||
|         <> | ||||
|           <div | ||||
|             {...column.getSortByToggleProps()} | ||||
|             className={tableStyles.headerCellLabel} | ||||
|             title={column.render('Header')} | ||||
|           > | ||||
|             <div>{column.render('Header')}</div> | ||||
|             <div> | ||||
|               {column.isSorted && (column.isSortedDesc ? <Icon name="arrow-down" /> : <Icon name="arrow-up" />)} | ||||
|             </div> | ||||
|           </div> | ||||
|           {column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} | ||||
|         </> | ||||
|       )} | ||||
|       {!column.canSort && column.render('Header')} | ||||
|       {!column.canSort && column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} | ||||
|       {column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -71,6 +71,14 @@ export const getTableStyles = (theme: GrafanaTheme2) => { | |||
|       background: ${headerBg}; | ||||
|       position: relative; | ||||
|     `,
 | ||||
|     tfoot: css` | ||||
|       label: tfoot; | ||||
|       height: ${cellHeight}px; | ||||
|       overflow-y: auto; | ||||
|       overflow-x: hidden; | ||||
|       background: ${headerBg}; | ||||
|       position: relative; | ||||
|     `,
 | ||||
|     headerCell: css` | ||||
|       padding: ${cellPadding}px; | ||||
|       overflow: hidden; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { CellProps } from 'react-table'; | ||||
| import { Field } from '@grafana/data'; | ||||
| import { Field, KeyValue } from '@grafana/data'; | ||||
| import { TableStyles } from './styles'; | ||||
| import { CSSProperties, FC } from 'react'; | ||||
| 
 | ||||
|  | @ -31,3 +31,5 @@ export interface TableCellProps extends CellProps<any> { | |||
| } | ||||
| 
 | ||||
| export type CellComponent = FC<TableCellProps>; | ||||
| 
 | ||||
| export type FooterItem = Array<KeyValue<string>> | string | undefined; | ||||
|  |  | |||
|  | @ -12,9 +12,10 @@ import { | |||
| 
 | ||||
| import { DefaultCell } from './DefaultCell'; | ||||
| import { BarGaugeCell } from './BarGaugeCell'; | ||||
| import { TableCellDisplayMode, TableFieldOptions } from './types'; | ||||
| import { CellComponent, TableCellDisplayMode, TableFieldOptions, FooterItem } from './types'; | ||||
| import { JSONViewCell } from './JSONViewCell'; | ||||
| import { ImageCell } from './ImageCell'; | ||||
| import { getFooterValue } from './FooterRow'; | ||||
| 
 | ||||
| export function getTextAlign(field?: Field): ContentPosition { | ||||
|   if (!field) { | ||||
|  | @ -41,7 +42,12 @@ export function getTextAlign(field?: Field): ContentPosition { | |||
|   return 'flex-start'; | ||||
| } | ||||
| 
 | ||||
| export function getColumns(data: DataFrame, availableWidth: number, columnMinWidth: number): Column[] { | ||||
| export function getColumns( | ||||
|   data: DataFrame, | ||||
|   availableWidth: number, | ||||
|   columnMinWidth: number, | ||||
|   footerValues?: FooterItem[] | ||||
| ): Column[] { | ||||
|   const columns: any[] = []; | ||||
|   let fieldCountWithoutWidth = data.fields.length; | ||||
| 
 | ||||
|  | @ -81,6 +87,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid | |||
|       minWidth: fieldTableOptions.minWidth || columnMinWidth, | ||||
|       filter: memoizeOne(filterByValue(field)), | ||||
|       justifyContent: getTextAlign(field), | ||||
|       Footer: getFooterValue(fieldIndex, footerValues), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|  | @ -108,7 +115,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid | |||
|   return columns; | ||||
| } | ||||
| 
 | ||||
| function getCellComponent(displayMode: TableCellDisplayMode, field: Field) { | ||||
| function getCellComponent(displayMode: TableCellDisplayMode, field: Field): CellComponent { | ||||
|   switch (displayMode) { | ||||
|     case TableCellDisplayMode.ColorText: | ||||
|     case TableCellDisplayMode.ColorBackground: | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue