({});
@@ -286,6 +287,40 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
[onEditAction, sortBy, sortDirection]
);
+ const renderSummaryRow = useMemo(() => {
+ const columnsWithSummary = columnConfig.columns
+ .filter((col) => !!col.columnId && !col.hidden)
+ .map((config) => ({
+ columnId: config.columnId,
+ summaryRowValue: config.summaryRowValue,
+ ...getFinalSummaryConfiguration(config.columnId, config, firstTable),
+ }))
+ .filter(({ summaryRow }) => summaryRow !== 'none');
+
+ if (columnsWithSummary.length) {
+ const summaryLookup = Object.fromEntries(
+ columnsWithSummary.map(({ summaryRowValue, summaryLabel, columnId }) => [
+ columnId,
+ summaryLabel === '' ? `${summaryRowValue}` : `${summaryLabel}: ${summaryRowValue}`,
+ ])
+ );
+ return ({ columnId }: { columnId: string }) => {
+ const currentAlignment = alignments && alignments[columnId];
+ const alignmentClassName = `lnsTableCell--${currentAlignment}`;
+ const columnName =
+ columns.find(({ id }) => id === columnId)?.displayAsText?.replace(/ /g, '-') || columnId;
+ return summaryLookup[columnId] != null ? (
+
+ {summaryLookup[columnId]}
+
+ ) : null;
+ };
+ }
+ }, [columnConfig.columns, alignments, firstTable, columns]);
+
if (isEmpty) {
return ;
}
@@ -323,6 +358,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
sorting={sorting}
onColumnResize={onColumnResize}
toolbarVisibility={false}
+ renderFooterCellValue={renderSummaryRow}
/>
diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx
index 2d5f4aea98856..79a541b0288ab 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx
@@ -28,10 +28,12 @@ import { ColumnState } from './visualization';
import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types';
import type { DatatableRender } from './components/types';
import { transposeTable } from './transpose_helpers';
+import { computeSummaryRowForColumn } from './summary';
export type ColumnConfigArg = Omit & {
type: 'lens_datatable_column';
palette?: PaletteOutput;
+ summaryRowValue?: unknown;
};
export interface Args {
@@ -116,6 +118,16 @@ export const getDatatable = ({
return memo;
}, {});
+ const columnsWithSummary = args.columns.filter((c) => c.summaryRow);
+ for (const column of columnsWithSummary) {
+ column.summaryRowValue = computeSummaryRowForColumn(
+ column,
+ firstTable,
+ formatters,
+ formatFactory({ id: 'number' })
+ );
+ }
+
if (sortBy && columnsReverseLookup[sortBy] && sortDirection !== 'none') {
// Sort on raw values for these types, while use the formatted value for the rest
const sortingCriteria = getSortingCriteria(
@@ -173,6 +185,8 @@ export const datatableColumn: ExpressionFunctionDefinition<
types: ['palette'],
help: '',
},
+ summaryRow: { types: ['string'], help: '' },
+ summaryLabel: { types: ['string'], help: '' },
},
fn: function fn(input: unknown, args: ColumnState) {
return {
diff --git a/x-pack/plugins/lens/public/datatable_visualization/summary.test.ts b/x-pack/plugins/lens/public/datatable_visualization/summary.test.ts
new file mode 100644
index 0000000000000..f92c83fbbfdc8
--- /dev/null
+++ b/x-pack/plugins/lens/public/datatable_visualization/summary.test.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { IFieldFormat } from 'src/plugins/data/public';
+import { Datatable } from 'src/plugins/expressions';
+import { computeSummaryRowForColumn, getFinalSummaryConfiguration } from './summary';
+
+describe('Summary row helpers', () => {
+ const mockNumericTable: Datatable = {
+ type: 'datatable',
+ columns: [{ id: 'myColumn', name: 'My Column', meta: { type: 'number' } }],
+ rows: [{ myColumn: 45 }],
+ };
+
+ const mockNumericTableWithArray: Datatable = {
+ type: 'datatable',
+ columns: [{ id: 'myColumn', name: 'My Column', meta: { type: 'number' } }],
+ rows: [{ myColumn: [45, 90] }],
+ };
+
+ const mockNonNumericTable: Datatable = {
+ type: 'datatable',
+ columns: [{ id: 'myColumn', name: 'My Column', meta: { type: 'string' } }],
+ rows: [{ myColumn: 'myString' }],
+ };
+
+ const defaultFormatter = { convert: (x) => x } as IFieldFormat;
+ const customNumericFormatter = { convert: (x: number) => x.toFixed(2) } as IFieldFormat;
+
+ describe('getFinalSummaryConfiguration', () => {
+ it('should return the base configuration for an unconfigured column', () => {
+ expect(getFinalSummaryConfiguration('myColumn', {}, mockNumericTable)).toEqual({
+ summaryRow: 'none',
+ summaryLabel: 'None',
+ });
+ });
+
+ it('should return the right configuration for a partially configured column', () => {
+ expect(
+ getFinalSummaryConfiguration('myColumn', { summaryRow: 'sum' }, mockNumericTable)
+ ).toEqual({
+ summaryRow: 'sum',
+ summaryLabel: 'Sum',
+ });
+ });
+
+ it('should return the base configuration for a transitioned invalid column', () => {
+ expect(
+ getFinalSummaryConfiguration('myColumn', { summaryRow: 'sum' }, mockNumericTableWithArray)
+ ).toEqual({
+ summaryRow: 'sum',
+ summaryLabel: 'Sum',
+ });
+ });
+
+ it('should return the base configuration for a non numeric column', () => {
+ expect(
+ getFinalSummaryConfiguration('myColumn', { summaryRow: 'sum' }, mockNonNumericTable)
+ ).toEqual({
+ summaryRow: 'none',
+ summaryLabel: 'None',
+ });
+ });
+ });
+
+ describe('computeSummaryRowForColumn', () => {
+ for (const op of ['avg', 'sum', 'min', 'max'] as const) {
+ it(`should return formatted value for a ${op} summary function`, () => {
+ expect(
+ computeSummaryRowForColumn(
+ { summaryRow: op, columnId: 'myColumn', type: 'lens_datatable_column' },
+ mockNumericTable,
+ {
+ myColumn: customNumericFormatter,
+ },
+ defaultFormatter
+ )
+ ).toBe('45.00');
+ });
+ }
+
+ it('should ignore the column formatter, rather return the raw value for count operation', () => {
+ expect(
+ computeSummaryRowForColumn(
+ { summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
+ mockNumericTable,
+ {
+ myColumn: customNumericFormatter,
+ },
+ defaultFormatter
+ )
+ ).toBe(1);
+ });
+
+ it('should only count non-null/empty values', () => {
+ expect(
+ computeSummaryRowForColumn(
+ { summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
+ { ...mockNumericTable, rows: [...mockNumericTable.rows, { myColumn: null }] },
+ {
+ myColumn: customNumericFormatter,
+ },
+ defaultFormatter
+ )
+ ).toBe(1);
+ });
+
+ it('should count numeric arrays as valid and distinct values', () => {
+ expect(
+ computeSummaryRowForColumn(
+ { summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
+ mockNumericTableWithArray,
+ {
+ myColumn: defaultFormatter,
+ },
+ defaultFormatter
+ )
+ ).toBe(2);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/datatable_visualization/summary.ts b/x-pack/plugins/lens/public/datatable_visualization/summary.ts
new file mode 100644
index 0000000000000..6c267445aab76
--- /dev/null
+++ b/x-pack/plugins/lens/public/datatable_visualization/summary.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { FieldFormat } from 'src/plugins/data/public';
+import { Datatable } from 'src/plugins/expressions/public';
+import { ColumnConfigArg } from './datatable_visualization';
+import { getOriginalId } from './transpose_helpers';
+import { isNumericField } from './utils';
+
+type SummaryRowType = Extract;
+
+export function getFinalSummaryConfiguration(
+ columnId: string,
+ columnArgs: Pick | undefined,
+ table: Datatable | undefined
+) {
+ const isNumeric = isNumericField(table, columnId);
+
+ const summaryRow = isNumeric ? columnArgs?.summaryRow || 'none' : 'none';
+ const summaryLabel = columnArgs?.summaryLabel ?? getDefaultSummaryLabel(summaryRow);
+
+ return {
+ summaryRow,
+ summaryLabel,
+ };
+}
+
+export function getDefaultSummaryLabel(type: SummaryRowType) {
+ return getSummaryRowOptions().find(({ value }) => type === value)!.label!;
+}
+
+export function getSummaryRowOptions(): Array<{
+ value: SummaryRowType;
+ label: string;
+ 'data-test-subj': string;
+}> {
+ return [
+ {
+ value: 'none',
+ label: i18n.translate('xpack.lens.table.summaryRow.none', {
+ defaultMessage: 'None',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-none',
+ },
+ {
+ value: 'count',
+ label: i18n.translate('xpack.lens.table.summaryRow.count', {
+ defaultMessage: 'Value count',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-count',
+ },
+ {
+ value: 'sum',
+ label: i18n.translate('xpack.lens.table.summaryRow.sum', {
+ defaultMessage: 'Sum',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-sum',
+ },
+ {
+ value: 'avg',
+ label: i18n.translate('xpack.lens.table.summaryRow.average', {
+ defaultMessage: 'Average',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-avg',
+ },
+ {
+ value: 'min',
+ label: i18n.translate('xpack.lens.table.summaryRow.minimum', {
+ defaultMessage: 'Minimum',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-min',
+ },
+ {
+ value: 'max',
+ label: i18n.translate('xpack.lens.table.summaryRow.maximum', {
+ defaultMessage: 'Maximum',
+ }),
+ 'data-test-subj': 'lns-datatable-summary-max',
+ },
+ ];
+}
+
+export function computeSummaryRowForColumn(
+ columnArgs: ColumnConfigArg,
+ table: Datatable,
+ formatters: Record,
+ defaultFormatter: FieldFormat
+) {
+ const summaryValue = computeFinalValue(columnArgs.summaryRow, columnArgs.columnId, table.rows);
+ // ignore the coluymn formatter for the count case
+ if (columnArgs.summaryRow === 'count') {
+ return defaultFormatter.convert(summaryValue);
+ }
+ return formatters[getOriginalId(columnArgs.columnId)].convert(summaryValue);
+}
+
+function computeFinalValue(
+ type: ColumnConfigArg['summaryRow'],
+ columnId: string,
+ rows: Datatable['rows']
+) {
+ // flatten the row structure, to easier handle numeric arrays
+ const validRows = rows.filter((v) => v[columnId] != null).flatMap((v) => v[columnId]);
+ const count = validRows.length;
+ const sum = validRows.reduce((partialSum: number, value: number) => {
+ return partialSum + value;
+ }, 0);
+ switch (type) {
+ case 'sum':
+ return sum;
+ case 'count':
+ return count;
+ case 'avg':
+ return sum / count;
+ case 'min':
+ return Math.min(...validRows);
+ case 'max':
+ return Math.max(...validRows);
+ default:
+ throw Error('No summary function found');
+ }
+}
diff --git a/x-pack/plugins/lens/public/datatable_visualization/utils.ts b/x-pack/plugins/lens/public/datatable_visualization/utils.ts
new file mode 100644
index 0000000000000..64fdee233e830
--- /dev/null
+++ b/x-pack/plugins/lens/public/datatable_visualization/utils.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Datatable } from 'src/plugins/expressions/public';
+import { getOriginalId } from './transpose_helpers';
+
+function isValidNumber(value: unknown): boolean {
+ return typeof value === 'number' || value == null;
+}
+
+export function isNumericField(currentData: Datatable | undefined, accessor: string) {
+ const isNumeric =
+ currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor)
+ ?.meta.type === 'number';
+
+ return (
+ isNumeric &&
+ currentData?.rows.every((row) => {
+ const val = row[accessor];
+ return isValidNumber(val) || (Array.isArray(val) && val.every(isValidNumber));
+ })
+ );
+}
diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
index efde4160019e7..e48cb1b28c084 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
@@ -23,6 +23,7 @@ import { TableDimensionEditor } from './components/dimension_editor';
import { CUSTOM_PALETTE } from '../shared_components/coloring/constants';
import { CustomPaletteParams } from '../shared_components/coloring/types';
import { getStopsForFixedMode } from '../shared_components';
+import { getDefaultSummaryLabel } from './summary';
export interface ColumnState {
columnId: string;
@@ -38,6 +39,8 @@ export interface ColumnState {
alignment?: 'left' | 'right' | 'center';
palette?: PaletteOutput;
colorMode?: 'none' | 'cell' | 'text';
+ summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
+ summaryLabel?: string;
}
export interface SortingState {
@@ -358,6 +361,8 @@ export const getDatatableVisualization = ({
reverse: false, // managed at UI level
};
+ const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
+
return {
type: 'expression',
chain: [
@@ -376,6 +381,10 @@ export const getDatatableVisualization = ({
alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment],
colorMode: [column.colorMode ?? 'none'],
palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)],
+ summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!],
+ summaryLabel: hasNoSummaryRow
+ ? []
+ : [column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!)],
},
},
],
diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.test.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.test.ts
new file mode 100644
index 0000000000000..7aa93fcad95e9
--- /dev/null
+++ b/x-pack/plugins/lens/public/shared_components/debounced_value.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useDebouncedValue } from './debounced_value';
+
+jest.mock('lodash', () => {
+ const original = jest.requireActual('lodash');
+
+ return {
+ ...original,
+ debounce: (fn: unknown) => fn,
+ };
+});
+
+describe('useDebouncedValue', () => {
+ it('should update upstream value changes', () => {
+ const onChangeMock = jest.fn();
+ const { result } = renderHook(() => useDebouncedValue({ value: 'a', onChange: onChangeMock }));
+
+ act(() => {
+ result.current.handleInputChange('b');
+ });
+
+ expect(onChangeMock).toHaveBeenCalledWith('b');
+ });
+
+ it('should fallback to initial value with empty string (by default)', () => {
+ const onChangeMock = jest.fn();
+ const { result } = renderHook(() => useDebouncedValue({ value: 'a', onChange: onChangeMock }));
+
+ act(() => {
+ result.current.handleInputChange('');
+ });
+
+ expect(onChangeMock).toHaveBeenCalledWith('a');
+ });
+
+ it('should allow empty input to be updated', () => {
+ const onChangeMock = jest.fn();
+ const { result } = renderHook(() =>
+ useDebouncedValue({ value: 'a', onChange: onChangeMock }, { allowEmptyString: true })
+ );
+
+ act(() => {
+ result.current.handleInputChange('');
+ });
+
+ expect(onChangeMock).toHaveBeenCalledWith('');
+ });
+});
diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.ts
index 1f8ba0fa765b2..5525f6b16b316 100644
--- a/x-pack/plugins/lens/public/shared_components/debounced_value.ts
+++ b/x-pack/plugins/lens/public/shared_components/debounced_value.ts
@@ -13,15 +13,19 @@ import { debounce } from 'lodash';
* are in flight because the user is currently modifying the value.
*/
-export const useDebouncedValue = ({
- onChange,
- value,
-}: {
- onChange: (val: T) => void;
- value: T;
-}) => {
+export const useDebouncedValue = (
+ {
+ onChange,
+ value,
+ }: {
+ onChange: (val: T) => void;
+ value: T;
+ },
+ { allowEmptyString }: { allowEmptyString?: boolean } = {}
+) => {
const [inputValue, setInputValue] = useState(value);
const unflushedChanges = useRef(false);
+ const shouldUpdateWithEmptyString = Boolean(allowEmptyString);
// Save the initial value
const initialValue = useRef(value);
@@ -45,7 +49,10 @@ export const useDebouncedValue = ({
const handleInputChange = (val: T) => {
setInputValue(val);
- onChangeDebounced(val || initialValue.current);
+ const valueToUpload = shouldUpdateWithEmptyString
+ ? val ?? initialValue.current
+ : val || initialValue.current;
+ onChangeDebounced(valueToUpload);
};
return { inputValue, handleInputChange, initialValue: initialValue.current };
diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts
index f048bf47991f2..f499c5bf0cfe8 100644
--- a/x-pack/test/functional/apps/lens/table.ts
+++ b/x-pack/test/functional/apps/lens/table.ts
@@ -143,6 +143,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(styleObj['background-color']).to.be('rgb(168, 191, 218)');
// should also set text color when in cell mode
expect(styleObj.color).to.be('rgb(0, 0, 0)');
+ await PageObjects.lens.closeTablePalettePanel();
+ });
+
+ it('should allow to show a summary table for metric columns', async () => {
+ await PageObjects.lens.setTableSummaryRowFunction('sum');
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.lens.assertExactText(
+ '[data-test-subj="lnsDataTable-footer-169.228.188.120-›-Average-of-bytes"]',
+ 'Sum: 18,994'
+ );
});
});
}
diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts
index b8f1e6b3dd236..c0111afad2893 100644
--- a/x-pack/test/functional/page_objects/lens_page.ts
+++ b/x-pack/test/functional/page_objects/lens_page.ts
@@ -761,6 +761,20 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
return buttonEl.click();
},
+ async setTableSummaryRowFunction(
+ summaryFunction: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max'
+ ) {
+ await testSubjects.click('lnsDatatable_summaryrow_function');
+ await testSubjects.click('lns-datatable-summary-' + summaryFunction);
+ },
+
+ async setTableSummaryRowLabel(newLabel: string) {
+ await testSubjects.setValue('lnsDatatable_summaryrow_label', newLabel, {
+ clearWithKeyboard: true,
+ typeCharByChar: true,
+ });
+ },
+
async setTableDynamicColoring(coloringType: 'none' | 'cell' | 'text') {
await testSubjects.click('lnsDatatable_dynamicColoring_groups_' + coloringType);
},
@@ -769,6 +783,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.click('lnsDatatable_dynamicColoring_trigger');
},
+ async closeTablePalettePanel() {
+ await testSubjects.click('lns-indexPattern-PalettePanelContainerBack');
+ },
+
// different picker from the next one
async changePaletteTo(paletteName: string) {
await testSubjects.click('lnsDatatable_dynamicColoring_palette_picker');