mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
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:
parent
7ba86dc1dc
commit
16af756d50
@ -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.
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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] });
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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]);
|
||||
|
@ -50,4 +50,5 @@ export interface TableFooterCalc {
|
||||
reducer: string[]; // actually 1 value
|
||||
fields?: string[];
|
||||
enablePagination?: boolean;
|
||||
countRows?: boolean;
|
||||
}
|
||||
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ export const defaultPanelOptions: PanelOptions = {
|
||||
footer: {
|
||||
show: false,
|
||||
reducer: [],
|
||||
countRows: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user