mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
46
packages/grafana-ui/src/components/Table/FooterCell.tsx
Normal file
46
packages/grafana-ui/src/components/Table/FooterCell.tsx
Normal 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> </span>;
|
||||
};
|
94
packages/grafana-ui/src/components/Table/FooterRow.tsx
Normal file
94
packages/grafana-ui/src/components/Table/FooterRow.tsx
Normal 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] });
|
||||
}
|
74
packages/grafana-ui/src/components/Table/HeaderRow.tsx
Normal file
74
packages/grafana-ui/src/components/Table/HeaderRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
Block a user