diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 594a5019e7c..0e7e01ad200 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -78,6 +78,7 @@ export const Components = { }, Table: { header: 'table header', + footer: 'table-footer', }, }, }, diff --git a/packages/grafana-ui/src/components/Table/FooterCell.tsx b/packages/grafana-ui/src/components/Table/FooterCell.tsx new file mode 100644 index 00000000000..98a950d637d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/FooterCell.tsx @@ -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 {props.value}; + } + if (props.value && Array.isArray(props.value) && props.value.length > 0) { + return ( + + ); + } + return EmptyCell; +}; + +export const EmptyCell = (props: any) => { + return  ; +}; diff --git a/packages/grafana-ui/src/components/Table/FooterRow.tsx b/packages/grafana-ui/src/components/Table/FooterRow.tsx new file mode 100644 index 00000000000..3f77f923f47 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/FooterRow.tsx @@ -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 ( + + {footerGroups.map((footerGroup: HeaderGroup) => { + const { key, ...footerGroupProps } = footerGroup.getFooterGroupProps(); + return ( + + + {footerGroup.headers.map((column: ColumnInstance, index: number) => + renderFooterCell(column, tableStyles, height) + )} + + + ); + })} +
+ ); +}; + +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 ( + + {column.render('Footer')} + + ); +} + +export function getFooterValue(index: number, footerValues?: FooterItem[]) { + if (footerValues === undefined) { + return EmptyCell; + } + + return FooterCell({ value: footerValues[index] }); +} diff --git a/packages/grafana-ui/src/components/Table/HeaderRow.tsx b/packages/grafana-ui/src/components/Table/HeaderRow.tsx new file mode 100644 index 00000000000..15339c470bb --- /dev/null +++ b/packages/grafana-ui/src/components/Table/HeaderRow.tsx @@ -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 ( +
+ {headerGroups.map((headerGroup: HeaderGroup) => { + const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); + return ( +
+ {headerGroup.headers.map((column: Column, index: number) => + renderHeaderCell(column, tableStyles, data.fields[index]) + )} +
+ ); + })} +
+ ); +}; + +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 ( +
+ {column.canSort && ( + <> +
+
{column.render('Header')}
+
+ {column.isSorted && (column.isSortedDesc ? : )} +
+
+ {column.canFilter && } + + )} + {!column.canSort && column.render('Header')} + {!column.canSort && column.canFilter && } + {column.canResize &&
} +
+ ); +} diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index 80035dc4140..e0674b577b4 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -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): 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) => {
); }; + +export const Footer: Story = (args) => { + const theme = useTheme2(); + const data = buildData(theme, {}); + const footer = buildFooterData(data); + + return ( +
+ + + ); +}; diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index bbe2d544c4b..b6dbb7e2d6b 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -91,7 +91,11 @@ function getTestContext(propOverrides: Partial = {}) { } 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(); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index a9dc615e00f..2def06e4e7a 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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 = memo((props: Props) => { noHeader, resizable = true, initialSortBy, + footerValues, } = props; const tableStyles = useStyles2(getTableStyles); @@ -140,7 +140,12 @@ export const Table: FC = 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 = 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 = memo((props: Props) => { const headerHeight = noHeader ? 0 : tableStyles.cellHeight; return ( -
+
- {!noHeader && ( -
- {headerGroups.map((headerGroup: HeaderGroup) => { - const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); - return ( -
- {headerGroup.headers.map((column: Column, index: number) => - renderHeaderCell(column, tableStyles, data.fields[index]) - )} -
- ); - })} -
- )} + {!noHeader && } {rows.length > 0 ? ( = memo((props: Props) => { No data
)} +
@@ -240,37 +228,3 @@ export const Table: FC = 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 ( -
- {column.canSort && ( - <> -
-
{column.render('Header')}
-
- {column.isSorted && (column.isSortedDesc ? : )} -
-
- {column.canFilter && } - - )} - {!column.canSort && column.render('Header')} - {!column.canSort && column.canFilter && } - {column.canResize &&
} -
- ); -} diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index d9bb1770a0a..15577e29a10 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -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; diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index f8481c5666f..78b97cb0578 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -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 { } export type CellComponent = FC; + +export type FooterItem = Array> | string | undefined; diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 4a5bbd1dc5d..9ce18afdcd8 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -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: