diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index 88948e9a7615b..49003af28f3f1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiButtonGroup } from '@elastic/eui'; +import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui'; import { FramePublicAPI, VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; @@ -212,4 +212,64 @@ describe('data table dimension editor', () => { expect(instance.find(PalettePanelContainer).exists()).toBe(true); }); + + it('should show the summary field for non numeric columns', () => { + const instance = mountWithIntl(); + expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_function"]').exists()).toBe( + false + ); + expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_label"]').exists()).toBe(false); + }); + + it('should set the summary row function default to "none"', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_summaryrow_function"]') + .find(EuiComboBox) + .prop('selectedOptions') + ).toEqual([{ value: 'none', label: 'None' }]); + + expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_label"]').exists()).toBe(false); + }); + + it('should show the summary row label input ony when summary row is different from "none"', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns[0].summaryRow = 'sum'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_summaryrow_function"]') + .find(EuiComboBox) + .prop('selectedOptions') + ).toEqual([{ value: 'sum', label: 'Sum' }]); + + expect( + instance + .find('[data-test-subj="lnsDatatable_summaryrow_label"]') + .find(EuiFieldText) + .prop('value') + ).toBe('Sum'); + }); + + it("should show the correct summary row name when user's changes summary label", () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns[0].summaryRow = 'sum'; + state.columns[0].summaryLabel = 'MySum'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_summaryrow_function"]') + .find(EuiComboBox) + .prop('selectedOptions') + ).toEqual([{ value: 'sum', label: 'Sum' }]); + + expect( + instance + .find('[data-test-subj="lnsDatatable_summaryrow_label"]') + .find(EuiFieldText) + .prop('value') + ).toBe('MySum'); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 76c47a9c743c5..6c39a04ae1504 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -16,10 +16,12 @@ import { EuiFlexItem, EuiFlexGroup, EuiButtonEmpty, + EuiFieldText, + EuiComboBox, } from '@elastic/eui'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { VisualizationDimensionEditorProps } from '../../types'; -import { DatatableVisualizationState } from '../visualization'; +import { ColumnState, DatatableVisualizationState } from '../visualization'; import { getOriginalId } from '../transpose_helpers'; import { CustomizablePalette, @@ -27,14 +29,22 @@ import { defaultPaletteParams, FIXED_PROGRESSION, getStopsForFixedMode, + useDebouncedValue, } from '../../shared_components/'; import { PalettePanelContainer } from './palette_panel_container'; import { findMinMaxByColumnId } from './shared_utils'; import './dimension_editor.scss'; +import { + getDefaultSummaryLabel, + getFinalSummaryConfiguration, + getSummaryRowOptions, +} from '../summary'; +import { isNumericField } from '../utils'; const idPrefix = htmlIdGenerator()(); type ColumnType = DatatableVisualizationState['columns'][number]; +type SummaryRowType = Extract; function updateColumnWith( state: DatatableVisualizationState, @@ -58,6 +68,24 @@ export function TableDimensionEditor( const { state, setState, frame, accessor } = props; const column = state.columns.find(({ columnId }) => accessor === columnId); const [isPaletteOpen, setIsPaletteOpen] = useState(false); + const onSummaryLabelChangeToDebounce = useCallback( + (newSummaryLabel: string | undefined) => { + setState({ + ...state, + columns: updateColumnWith(state, accessor, { summaryLabel: newSummaryLabel }), + }); + }, + [accessor, setState, state] + ); + const { inputValue: summaryLabel, handleInputChange: onSummaryLabelChange } = useDebouncedValue< + string | undefined + >( + { + onChange: onSummaryLabelChangeToDebounce, + value: column?.summaryLabel, + }, + { allowEmptyString: true } // empty string is a valid label for this feature + ); if (!column) return null; if (column.isTransposed) return null; @@ -65,13 +93,16 @@ export function TableDimensionEditor( const currentData = frame.activeData?.[state.layerId]; // either read config state or use same logic as chart itself - const isNumericField = - currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) - ?.meta.type === 'number'; - - const currentAlignment = column?.alignment || (isNumericField ? 'right' : 'left'); + const isNumeric = isNumericField(currentData, accessor); + const currentAlignment = column?.alignment || (isNumeric ? 'right' : 'left'); const currentColorMode = column?.colorMode || 'none'; const hasDynamicColoring = currentColorMode !== 'none'; + // when switching from one operation to another, make sure to keep the configuration consistent + const { summaryRow, summaryLabel: fallbackSummaryLabel } = getFinalSummaryConfiguration( + accessor, + column, + currentData + ); const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; @@ -175,7 +206,61 @@ export function TableDimensionEditor( /> )} - {isNumericField && ( + {isNumeric && ( + <> + + { + const newValue = choices[0].value as SummaryRowType; + setState({ + ...state, + columns: updateColumnWith(state, accessor, { summaryRow: newValue }), + }); + }} + /> + + {summaryRow !== 'none' && ( + + { + onSummaryLabelChange(e.target.value); + }} + /> + + )} + + )} + {isNumeric && ( <> { c: { min: 3, max: 3 }, }); }); + + test('it does render a summary footer if at least one column has it configured', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} + /> + ); + expect(wrapper.find('[data-test-subj="lnsDataTable-footer-a"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').first().text()).toEqual( + 'Sum: 3' + ); + }); + + test('it does render a summary footer with just the raw value for empty label', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} + /> + ); + + expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').first().text()).toEqual('3'); + }); + + test('it does not render the summary row if the only column with summary is hidden', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} + /> + ); + + expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index e6fcf3f321f7f..cd990149fdaf5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -43,6 +43,7 @@ import { } from './table_actions'; import { findMinMaxByColumnId } from './shared_utils'; import { CUSTOM_PALETTE } from '../../shared_components/coloring/constants'; +import { getFinalSummaryConfiguration } from '../summary'; export const DataContext = React.createContext({}); @@ -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');