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" >}}
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;
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;

View File

@ -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))}
</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) {
return EmptyCell;
}
if (isCountRowsSet) {
const count = footerValues[index];
if (typeof count !== 'string') {
return EmptyCell;
}
return FooterCell({ value: [{ Count: count }] });
}
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', () => {
it('then correct rows should be rendered and new table is rendered when expander is clicked', () => {
getTestContext({

View File

@ -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]);

View File

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

View File

@ -68,7 +68,8 @@ export function getColumns(
expandedIndexes: Set<number>,
setExpandedIndexes: (indexes: Set<number>) => 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),
});
}

View File

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

View File

@ -131,6 +131,14 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(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<PanelOptions, TableFieldOptions>(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',