TablePanel: Add support for Count calculation per column or per entire dataset (#58134)

* WIP

* TablePanel: Add support for Count calculation per column or per entire dataset

* refactor

* refactor

* refactor + fixes

* refactor + tests

* Docs and cue model fix
This commit is contained in:
Victor Marin 2022-11-28 10:16:35 +02:00 committed by GitHub
parent 7ba86dc1dc
commit 16af756d50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 192 additions and 21 deletions

View File

@ -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" >}} {{< 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**. 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.

View File

@ -291,7 +291,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero:
} as FieldCalcs; } as FieldCalcs;
const data = field.values; 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; const isNumberField = field.type === FieldType.number || FieldType.time;

View File

@ -40,9 +40,7 @@ export const FooterRow = (props: FooterRowProps) => {
data-testid={e2eSelectorsTable.footer} data-testid={e2eSelectorsTable.footer}
style={height ? { height: `${height}px` } : undefined} style={height ? { height: `${height}px` } : undefined}
> >
{footerGroup.headers.map((column: ColumnInstance, index: number) => {footerGroup.headers.map((column: ColumnInstance) => renderFooterCell(column, tableStyles, height))}
renderFooterCell(column, tableStyles, height)
)}
</div> </div>
); );
})} })}
@ -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) { if (footerValues === undefined) {
return EmptyCell; return EmptyCell;
} }
if (isCountRowsSet) {
const count = footerValues[index];
if (typeof count !== 'string') {
return EmptyCell;
}
return FooterCell({ value: [{ Count: count }] });
}
return FooterCell({ value: footerValues[index] }); return FooterCell({ value: footerValues[index] });
} }

View File

@ -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(<Table {...props} />);
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual('4');
});
});
describe('when mounted with data and sub-data', () => { describe('when mounted with data and sub-data', () => {
it('then correct rows should be rendered and new table is rendered when expander is clicked', () => { it('then correct rows should be rendered and new table is rendered when expander is clicked', () => {
getTestContext({ getTestContext({

View File

@ -12,7 +12,7 @@ import {
import usePrevious from 'react-use/lib/usePrevious'; import usePrevious from 'react-use/lib/usePrevious';
import { VariableSizeList } from 'react-window'; 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 { useStyles2, useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
@ -188,10 +188,27 @@ export const Table = memo((props: Props) => {
return Array(data.length).fill(0); return Array(data.length).fill(0);
}, [data]); }, [data]);
const isCountRowsSet = Boolean(
footerOptions?.countRows &&
footerOptions.reducer &&
footerOptions.reducer.length &&
footerOptions.reducer[0] === ReducerID.count
);
// React-table column definitions // React-table column definitions
const memoizedColumns = useMemo( 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 // Internal react table state reducer
@ -244,17 +261,24 @@ export const Table = memo((props: Props) => {
return; return;
} }
if (footerOptions.show) { if (!footerOptions.show) {
setFooterItems(
getFooterItems(
headerGroups[0].headers as unknown as Array<{ field: Field }>,
createFooterCalculationValues(rows),
footerOptions,
theme
)
);
} else {
setFooterItems(undefined); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [footerOptions, theme, state.filters, data]); }, [footerOptions, theme, state.filters, data]);

View File

@ -50,4 +50,5 @@ export interface TableFooterCalc {
reducer: string[]; // actually 1 value reducer: string[]; // actually 1 value
fields?: string[]; fields?: string[];
enablePagination?: boolean; enablePagination?: boolean;
countRows?: boolean;
} }

View File

@ -68,7 +68,8 @@ export function getColumns(
expandedIndexes: Set<number>, expandedIndexes: Set<number>,
setExpandedIndexes: (indexes: Set<number>) => void, setExpandedIndexes: (indexes: Set<number>) => void,
expander: boolean, expander: boolean,
footerValues?: FooterItem[] footerValues?: FooterItem[],
isCountRowsSet?: boolean
): GrafanaTableColumn[] { ): GrafanaTableColumn[] {
const columns: GrafanaTableColumn[] = expander const columns: GrafanaTableColumn[] = expander
? [ ? [
@ -134,7 +135,7 @@ export function getColumns(
minWidth: fieldTableOptions.minWidth ?? columnMinWidth, minWidth: fieldTableOptions.minWidth ?? columnMinWidth,
filter: memoizeOne(filterByValue(field)), filter: memoizeOne(filterByValue(field)),
justifyContent: getTextAlign(field), justifyContent: getTextAlign(field),
Footer: getFooterValue(fieldIndex, footerValues), Footer: getFooterValue(fieldIndex, footerValues, isCountRowsSet),
}); });
} }

View File

@ -27,6 +27,7 @@ export const defaultPanelOptions: PanelOptions = {
footer: { footer: {
show: false, show: false,
reducer: [], reducer: [],
countRows: false,
}, },
}; };

View File

@ -131,6 +131,14 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(TablePane
defaultValue: [ReducerID.sum], defaultValue: [ReducerID.sum],
showIf: (cfg) => cfg.footer?.show, 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({ .addMultiSelect({
path: 'footer.fields', path: 'footer.fields',
category: [footerCategory], category: [footerCategory],
@ -156,7 +164,9 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(TablePane
}, },
}, },
defaultValue: '', 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({ .addCustomEditor({
id: 'footer.enablePagination', id: 'footer.enablePagination',