mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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" >}}
|
{{< 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.
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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] });
|
||||||
}
|
}
|
||||||
|
@ -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({
|
||||||
|
@ -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]);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ export const defaultPanelOptions: PanelOptions = {
|
|||||||
footer: {
|
footer: {
|
||||||
show: false,
|
show: false,
|
||||||
reducer: [],
|
reducer: [],
|
||||||
|
countRows: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user