diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index f795848e51b1..914e09ba23c5 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -160,3 +160,15 @@ Columns with filters applied have a blue funnel displayed next to the title. {{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}} To remove the filter, click the blue funnel icon and then click **Clear filter**. + +## Table footer + +You can use the table footer to show [calculations]({{< relref "../../calculation-types/" >}}) on fields. + +After enabling the table footer, you can select your **Calculation** and select the **Fields** that should be calculated. Not selecting any field apply the calculation to all numeric fields. + +### Count rows + +On selecting the **Count** calculation, you will see the **Count rows** switch. + +By enabling this option the footer will show the number of rows in the dataset instead of the number of values in the selected fields. diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index 59691aaf81cc..4ce873ab34c6 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -291,7 +291,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: } as FieldCalcs; const data = field.values; - calcs.count = data.length; + calcs.count = ignoreNulls ? data.length : data.toArray().filter((val) => val != null).length; const isNumberField = field.type === FieldType.number || FieldType.time; diff --git a/packages/grafana-ui/src/components/Table/FooterRow.tsx b/packages/grafana-ui/src/components/Table/FooterRow.tsx index 59a4659f3d8b..8e9cc1e423c1 100644 --- a/packages/grafana-ui/src/components/Table/FooterRow.tsx +++ b/packages/grafana-ui/src/components/Table/FooterRow.tsx @@ -40,9 +40,7 @@ export const FooterRow = (props: FooterRowProps) => { data-testid={e2eSelectorsTable.footer} style={height ? { height: `${height}px` } : undefined} > - {footerGroup.headers.map((column: ColumnInstance, index: number) => - renderFooterCell(column, tableStyles, height) - )} + {footerGroup.headers.map((column: ColumnInstance) => renderFooterCell(column, tableStyles, height))} ); })} @@ -71,10 +69,19 @@ function renderFooterCell(column: ColumnInstance, tableStyles: TableStyles, heig ); } -export function getFooterValue(index: number, footerValues?: FooterItem[]) { +export function getFooterValue(index: number, footerValues?: FooterItem[], isCountRowsSet?: boolean) { if (footerValues === undefined) { return EmptyCell; } + if (isCountRowsSet) { + const count = footerValues[index]; + if (typeof count !== 'string') { + return EmptyCell; + } + + return FooterCell({ value: [{ Count: count }] }); + } + return FooterCell({ value: footerValues[index] }); } diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index b6ce1f0d003c..95ca40bf7a56 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -399,6 +399,121 @@ describe('Table', () => { }); }); + describe('on table footer enabled and count calculation selected', () => { + it('should show count of non-null values', async () => { + getTestContext({ + footerOptions: { show: true, reducer: ['count'] }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual('4'); + }); + + it('should show count of rows when `count rows` is selected', async () => { + getTestContext({ + footerOptions: { show: true, reducer: ['count'], countRows: true }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number1', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual( + 'Count:' + ); + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5'); + }); + + it('should show correct counts when turning `count rows` on and off', async () => { + const { rerender } = getTestContext({ + footerOptions: { show: true, reducer: ['count'], countRows: true }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number1', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual( + 'Count:' + ); + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5'); + + const onSortByChange = jest.fn(); + const onCellFilterAdded = jest.fn(); + const onColumnResize = jest.fn(); + const props: Props = { + ariaLabel: 'aria-label', + data: getDefaultDataFrame(), + height: 600, + width: 800, + onSortByChange, + onCellFilterAdded, + onColumnResize, + }; + + const propOverrides = { + footerOptions: { show: true, reducer: ['count'], countRows: false }, + data: toDataFrame({ + name: 'A', + fields: [ + { + name: 'number', + type: FieldType.number, + values: [1, 1, 1, 2, null], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + }), + }; + + Object.assign(props, propOverrides); + + rerender(); + + expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual('4'); + }); + }); + describe('when mounted with data and sub-data', () => { it('then correct rows should be rendered and new table is rendered when expander is clicked', () => { getTestContext({ diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index dc3103067ecf..792d9a027fbb 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -12,7 +12,7 @@ import { import usePrevious from 'react-use/lib/usePrevious'; import { VariableSizeList } from 'react-window'; -import { DataFrame, getFieldDisplayName, Field } from '@grafana/data'; +import { DataFrame, getFieldDisplayName, Field, ReducerID } from '@grafana/data'; import { useStyles2, useTheme2 } from '../../themes'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; @@ -188,10 +188,27 @@ export const Table = memo((props: Props) => { return Array(data.length).fill(0); }, [data]); + const isCountRowsSet = Boolean( + footerOptions?.countRows && + footerOptions.reducer && + footerOptions.reducer.length && + footerOptions.reducer[0] === ReducerID.count + ); + // React-table column definitions const memoizedColumns = useMemo( - () => getColumns(data, width, columnMinWidth, expandedIndexes, setExpandedIndexes, !!subData?.length, footerItems), - [data, width, columnMinWidth, footerItems, subData, expandedIndexes] + () => + getColumns( + data, + width, + columnMinWidth, + expandedIndexes, + setExpandedIndexes, + !!subData?.length, + footerItems, + isCountRowsSet + ), + [data, width, columnMinWidth, footerItems, subData, expandedIndexes, isCountRowsSet] ); // Internal react table state reducer @@ -244,17 +261,24 @@ export const Table = memo((props: Props) => { return; } - if (footerOptions.show) { - setFooterItems( - getFooterItems( - headerGroups[0].headers as unknown as Array<{ field: Field }>, - createFooterCalculationValues(rows), - footerOptions, - theme - ) - ); - } else { + if (!footerOptions.show) { setFooterItems(undefined); + return; + } + + const footerItems = getFooterItems( + headerGroups[0].headers as unknown as Array<{ field: Field }>, + createFooterCalculationValues(rows), + footerOptions, + theme + ); + + if (isCountRowsSet) { + const footerItemsCountRows: FooterItem[] = new Array(footerItems.length).fill(undefined); + footerItemsCountRows[0] = data.length.toString(); + setFooterItems(footerItemsCountRows); + } else { + setFooterItems(footerItems); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [footerOptions, theme, state.filters, data]); diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 0f613eda42e2..6942ec7594bb 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -50,4 +50,5 @@ export interface TableFooterCalc { reducer: string[]; // actually 1 value fields?: string[]; enablePagination?: boolean; + countRows?: boolean; } diff --git a/packages/grafana-ui/src/components/Table/utils.tsx b/packages/grafana-ui/src/components/Table/utils.tsx index fdce74d68532..fd5f1b46c887 100644 --- a/packages/grafana-ui/src/components/Table/utils.tsx +++ b/packages/grafana-ui/src/components/Table/utils.tsx @@ -68,7 +68,8 @@ export function getColumns( expandedIndexes: Set, setExpandedIndexes: (indexes: Set) => void, expander: boolean, - footerValues?: FooterItem[] + footerValues?: FooterItem[], + isCountRowsSet?: boolean ): GrafanaTableColumn[] { const columns: GrafanaTableColumn[] = expander ? [ @@ -134,7 +135,7 @@ export function getColumns( minWidth: fieldTableOptions.minWidth ?? columnMinWidth, filter: memoizeOne(filterByValue(field)), justifyContent: getTextAlign(field), - Footer: getFooterValue(fieldIndex, footerValues), + Footer: getFooterValue(fieldIndex, footerValues, isCountRowsSet), }); } diff --git a/public/app/plugins/panel/table/models.gen.ts b/public/app/plugins/panel/table/models.gen.ts index cc01543b6d9a..c4d60a02e10a 100644 --- a/public/app/plugins/panel/table/models.gen.ts +++ b/public/app/plugins/panel/table/models.gen.ts @@ -27,6 +27,7 @@ export const defaultPanelOptions: PanelOptions = { footer: { show: false, reducer: [], + countRows: false, }, }; diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index ad19eba30a98..5872e486f2ba 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -131,6 +131,14 @@ export const plugin = new PanelPlugin(TablePane defaultValue: [ReducerID.sum], showIf: (cfg) => cfg.footer?.show, }) + .addBooleanSwitch({ + path: 'footer.countRows', + category: [footerCategory], + name: 'Count rows', + description: 'Display a single count for all data rows', + defaultValue: defaultPanelOptions.footer?.countRows, + showIf: (cfg) => cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] === ReducerID.count, + }) .addMultiSelect({ path: 'footer.fields', category: [footerCategory], @@ -156,7 +164,9 @@ export const plugin = new PanelPlugin(TablePane }, }, defaultValue: '', - showIf: (cfg) => cfg.footer?.show, + showIf: (cfg) => + (cfg.footer?.show && !cfg.footer?.countRows) || + (cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] !== ReducerID.count), }) .addCustomEditor({ id: 'footer.enablePagination',