Grafana UI: Table - add sticky table footer (#38094)

* table footer to allow showing summary data from the table panel
This commit is contained in:
Scott Lepper 2021-08-31 12:37:10 -04:00 committed by GitHub
parent 681de1ea89
commit 4f479de88e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 298 additions and 68 deletions

View File

@ -78,6 +78,7 @@ export const Components = {
},
Table: {
header: 'table header',
footer: 'table-footer',
},
},
},

View File

@ -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>&nbsp;</span>;
};

View File

@ -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] });
}

View File

@ -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>
);
}

View File

@ -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>
);
};

View File

@ -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();

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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: