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 (
+
+ {props.value.map((v: KeyValue, i) => {
+ const key = Object.keys(v)[0];
+ return (
+ -
+ {key}:
+ {v[key]}
+
+ );
+ })}
+
+ );
+ }
+ 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: