diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index d4491fbba00cf..e1e5f39a8cc48 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { DateHistogramIndexPatternColumn } from './date_histogram'; import { dateHistogramOperation } from '.'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { EuiSwitch } from '@elastic/eui'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from '@kbn/core/public'; @@ -18,6 +18,7 @@ import { dataPluginMock, getCalculateAutoTimeExpression } from '@kbn/data-plugin import { createMockedIndexPattern } from '../../mocks'; import type { IndexPatternLayer, IndexPattern } from '../../types'; import { getFieldByNameFactory } from '../../pure_helpers'; +import { act } from 'react-dom/test-utils'; const dataStart = dataPluginMock.createStartContract(); const unifiedSearchStart = unifiedSearchPluginMock.createStartContract(); @@ -312,8 +313,9 @@ describe('date_histogram', () => { /> ); - expect(instance.find('[data-test-subj="lensDateHistogramValue"]').prop('value')).toEqual(42); - expect(instance.find('[data-test-subj="lensDateHistogramUnit"]').prop('value')).toEqual('w'); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('selectedOptions') + ).toEqual([expect.objectContaining({ label: '42w' })]); }); it('should render current value for other index pattern', () => { @@ -348,11 +350,12 @@ describe('date_histogram', () => { /> ); - expect(instance.find('[data-test-subj="lensDateHistogramValue"]').prop('value')).toEqual(''); - expect(instance.find('[data-test-subj="lensDateHistogramUnit"]').prop('value')).toEqual('d'); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('selectedOptions') + ).toEqual([expect.objectContaining({ key: 'd' })]); }); - it('should render disabled switch and no time interval control for auto interval', () => { + it('should render time interval control set to auto for auto interval', () => { const thirdLayer: IndexPatternLayer = { indexPatternId: '1', columnOrder: ['col1'], @@ -382,9 +385,9 @@ describe('date_histogram', () => { indexPattern={indexPattern1} /> ); - expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy(); - expect(instance.find('[data-test-subj="lensDateHistogramUnit"]').exists()).toBeFalsy(); - expect(instance.find(EuiSwitch).at(1).prop('checked')).toBe(false); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('selectedOptions') + ).toEqual([expect.objectContaining({ key: 'auto' })]); }); it('should allow switching to manual interval', () => { @@ -461,7 +464,7 @@ describe('date_histogram', () => { ); instance .find(EuiSwitch) - .at(2) + .at(1) .simulate('change', { target: { checked: false }, }); @@ -502,16 +505,14 @@ describe('date_histogram', () => { indexPattern={{ ...indexPattern1, timeFieldName: undefined }} /> ); - instance - .find(EuiSwitch) - .at(1) - .simulate('change', { - target: { checked: false }, - }); + ( + instance + .find('[data-test-subj="lensDateHistogramInterval"]') + .prop('onChange') as unknown as (v: Array<{ key: string }>) => void + )([{ key: 'auto' }]); expect(updateLayerSpy).toHaveBeenCalled(); const newLayer = updateLayerSpy.mock.calls[0][0]; expect(newLayer).toHaveProperty('columns.col1.params.ignoreTimeRange', false); - expect(newLayer).toHaveProperty('columns.col1.params.interval', 'auto'); }); it('turns off drop partial bucket on tuning off time range ignore', () => { @@ -560,12 +561,14 @@ describe('date_histogram', () => { currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - instance.find('[data-test-subj="lensDateHistogramValue"]').simulate('change', { - target: { - value: '2', - }, - }); - expect(updateLayerSpy).toHaveBeenCalledWith(layerWithInterval('1w')); + ( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('onCreateOption') as ( + s: string + ) => void + )('2w'); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('isInvalid') + ).toBeTruthy(); }); it('should display error if an invalid interval is specified', () => { @@ -580,7 +583,9 @@ describe('date_histogram', () => { currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - expect(instance.find('[data-test-subj="lensDateHistogramError"]').exists()).toBeTruthy(); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('isInvalid') + ).toBeTruthy(); }); it('should not display error if interval value is blank', () => { @@ -595,7 +600,9 @@ describe('date_histogram', () => { currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - expect(instance.find('[data-test-subj="lensDateHistogramError"]').exists()).toBeFalsy(); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('isInvalid') + ).toBeFalsy(); }); it('should display error if interval value is 0', () => { @@ -610,12 +617,14 @@ describe('date_histogram', () => { currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - expect(instance.find('[data-test-subj="lensDateHistogramError"]').exists()).toBeTruthy(); + expect( + instance.find('[data-test-subj="lensDateHistogramInterval"]').prop('isInvalid') + ).toBeTruthy(); }); - it('should update the unit', () => { + it('should update the unit', async () => { const updateLayerSpy = jest.fn(); - const instance = shallow( + const instance = mount( { currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - instance.find('[data-test-subj="lensDateHistogramUnit"]').simulate('change', { - target: { - value: 'd', - }, + act(() => { + ( + instance + .find('[data-test-subj="lensDateHistogramInterval"]') + .at(0) + .prop('onCreateOption') as (s: string) => void + )('42d'); }); - expect(updateLayerSpy).toHaveBeenCalledWith(layerWithInterval('42d')); + expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual(layerWithInterval('42d')); }); it('should update the value', () => { const updateLayerSpy = jest.fn(); const testLayer = layerWithInterval('42d'); - const instance = shallow( + const instance = mount( { currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> ); - instance.find('[data-test-subj="lensDateHistogramValue"]').simulate('change', { - target: { - value: '9', - }, - }); - expect(updateLayerSpy).toHaveBeenCalledWith(layerWithInterval('9d')); + act(() => + ( + instance + .find('[data-test-subj="lensDateHistogramInterval"]') + .at(0) + .prop('onCreateOption') as (s: string) => void + )('9d') + ); + expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual(layerWithInterval('9d')); }); it('should not render options if they are restricted', () => { @@ -695,7 +710,7 @@ describe('date_histogram', () => { /> ); - expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy(); + expect(instance.find('[data-test-subj="lensDateHistogramInterval"]').exists()).toBeFalsy(); }); it('should allow the drop of partial buckets', () => { @@ -735,7 +750,7 @@ describe('date_histogram', () => { target: { checked: true }, }); expect(updateLayerSpy).toHaveBeenCalled(); - const newLayer = updateLayerSpy.mock.calls[0][0]; + const newLayer = updateLayerSpy.mock.calls[0][0](layer); expect(newLayer).toHaveProperty('columns.col1.params.dropPartials', true); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 56b82dc03101c..3b6d75879640d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -5,31 +5,27 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiBasicTable, EuiCode, - EuiFieldNumber, - EuiFlexGroup, - EuiFlexItem, + EuiComboBox, EuiFormRow, EuiIconTip, - EuiSelect, - EuiSpacer, EuiSwitch, EuiSwitchEvent, - EuiTextColor, } from '@elastic/eui'; import { AggFunctionsMapping, + AggParamOption, IndexPatternAggRestrictions, search, UI_SETTINGS, } from '@kbn/data-plugin/public'; -import { extendedBoundsToAst } from '@kbn/data-plugin/common'; +import { extendedBoundsToAst, intervalOptions } from '@kbn/data-plugin/common'; import { buildExpressionFunction } from '@kbn/expressions-plugin/public'; import { updateColumnParam } from '../layer_helpers'; import { OperationDefinition, ParamEditorProps } from '.'; @@ -184,54 +180,81 @@ export const dateHistogramOperation: OperationDefinition< const intervalIsRestricted = field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; - const interval = parseInterval(currentColumn.params.interval); + const [intervalInput, setIntervalInput] = useState(currentColumn.params.interval); + const interval = intervalInput === autoInterval ? autoInterval : parseInterval(intervalInput); // We force the interval value to 1 if it's empty, since that is the ES behavior, // and the isValidInterval function doesn't handle the empty case properly. Fixing // isValidInterval involves breaking changes in other areas. - const isValid = isValidInterval( - `${interval.value === '' ? '1' : interval.value}${interval.unit}`, - restrictedInterval(field!.aggregationRestrictions) - ); + const isValid = + (!currentColumn.params.ignoreTimeRange && intervalInput === autoInterval) || + (interval !== autoInterval && + intervalInput !== '' && + isValidInterval( + `${interval.value === '' ? '1' : interval.value}${interval.unit}`, + restrictedInterval(field!.aggregationRestrictions) + )); - const onChangeAutoInterval = useCallback( + const onChangeDropPartialBuckets = useCallback( (ev: EuiSwitchEvent) => { - const { fromDate, toDate } = dateRange; - const value = ev.target.checked - ? data.search.aggs.calculateAutoTimeExpression({ from: fromDate, to: toDate }) || '1h' - : autoInterval; - updateLayer( + updateLayer((newLayer) => updateColumnParam({ - layer: updateColumnParam({ layer, columnId, paramName: 'interval', value }), + layer: newLayer, columnId, - paramName: 'ignoreTimeRange', - value: false, + paramName: 'dropPartials', + value: ev.target.checked, }) ); }, - [dateRange, data.search.aggs, updateLayer, layer, columnId] + [columnId, updateLayer] ); - const onChangeDropPartialBuckets = useCallback( - (ev: EuiSwitchEvent) => { - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'dropPartials', - value: ev.target.checked, - }) + const setInterval = useCallback( + (newInterval: typeof interval) => { + const isCalendarInterval = + newInterval !== autoInterval && calendarOnlyIntervals.has(newInterval.unit); + const value = + newInterval === autoInterval + ? autoInterval + : `${isCalendarInterval ? '1' : newInterval.value}${newInterval.unit || 'd'}`; + + updateLayer((newLayer) => + updateColumnParam({ layer: newLayer, columnId, paramName: 'interval', value }) ); }, - [columnId, layer, updateLayer] + [columnId, updateLayer] ); - const setInterval = (newInterval: typeof interval) => { - const isCalendarInterval = calendarOnlyIntervals.has(newInterval.unit); - const value = `${isCalendarInterval ? '1' : newInterval.value}${newInterval.unit || 'd'}`; + const options = (intervalOptions || []) + .filter((option) => option.val !== autoInterval) + .map((option: AggParamOption) => { + return { label: option.display, key: option.val }; + }, []); - updateLayer(updateColumnParam({ layer, columnId, paramName: 'interval', value })); - }; + options.unshift({ + label: i18n.translate('xpack.lens.indexPattern.autoIntervalLabel', { + defaultMessage: 'Auto ({interval})', + values: { + interval: + data.search.aggs.calculateAutoTimeExpression({ + from: dateRange.fromDate, + to: dateRange.toDate, + }) || '1h', + }, + }), + key: autoInterval, + }); + + const definedOption = options.find((o) => o.key === intervalInput); + const selectedOptions = definedOption + ? [definedOption] + : [{ label: intervalInput, key: intervalInput }]; + + useEffect(() => { + if (isValid && intervalInput !== currentColumn.params.interval) { + setInterval(parseInterval(intervalInput)); + } + }, [intervalInput, isValid, currentColumn.params.interval, setInterval]); const bindToGlobalTimePickerValue = indexPattern.timeFieldName === field?.name || !currentColumn.params.ignoreTimeRange; @@ -260,187 +283,122 @@ export const dateHistogramOperation: OperationDefinition< /> - {!intervalIsRestricted && ( - - + {intervalIsRestricted ? ( + - - )} - {currentColumn.params.interval !== autoInterval && ( - <> - - {intervalIsRestricted ? ( - - ) : ( - <> - - - { - const newInterval = { - ...interval, - value: e.target.value, - }; - setInterval(newInterval); - }} - step={1} - /> - - - { - const newInterval = { - ...interval, - unit: e.target.value, - }; - setInterval(newInterval); - }} - isInvalid={!isValid} - options={[ - { - value: 'ms', - text: i18n.translate( - 'xpack.lens.indexPattern.dateHistogram.milliseconds', - { - defaultMessage: 'milliseconds', - } - ), - }, - { - value: 's', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.seconds', { - defaultMessage: 'seconds', - }), - }, - { - value: 'm', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.minutes', { - defaultMessage: 'minutes', - }), - }, - { - value: 'h', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.hours', { - defaultMessage: 'hours', - }), - }, - { - value: 'd', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.days', { - defaultMessage: 'days', - }), - }, - { - value: 'w', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.week', { - defaultMessage: 'week', - }), - }, - { - value: 'M', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.month', { - defaultMessage: 'month', - }), - }, - // Quarterly intervals appear to be unsupported by esaggs - { - value: 'y', - text: i18n.translate('xpack.lens.indexPattern.dateHistogram.year', { - defaultMessage: 'year', - }), - }, - ]} - /> - - - {!isValid && ( - <> - - - {i18n.translate('xpack.lens.indexPattern.invalidInterval', { - defaultMessage: 'Invalid interval value', - })} - - - )} - - )} - - - - {i18n.translate( - 'xpack.lens.indexPattern.dateHistogram.bindToGlobalTimePicker', - { - defaultMessage: 'Bind to global time picker', - } - )}{' '} - - - } - disabled={indexPattern.timeFieldName === field?.name} - checked={bindToGlobalTimePickerValue} - onChange={() => { + ) : ( + { + const newValue = opts.length ? opts[0].key! : ''; + setIntervalInput(newValue); + if (newValue === autoInterval && currentColumn.params.ignoreTimeRange) { updateLayer( updateColumnParam({ layer, columnId, paramName: 'ignoreTimeRange', - value: !currentColumn.params.ignoreTimeRange, + value: false, }) ); - }} - compressed - /> - - - )} + } + }} + onCreateOption={(customValue: string) => setIntervalInput(customValue.trim())} + options={options} + selectedOptions={selectedOptions} + singleSelection={{ asPlainText: true }} + placeholder={i18n.translate( + 'xpack.lens.indexPattern.dateHistogram.selectIntervalPlaceholder', + { + defaultMessage: 'Select an interval', + } + )} + /> + )} + + + + {i18n.translate('xpack.lens.indexPattern.dateHistogram.bindToGlobalTimePicker', { + defaultMessage: 'Bind to global time picker', + })}{' '} + + + } + disabled={indexPattern.timeFieldName === field?.name} + checked={bindToGlobalTimePickerValue} + onChange={() => { + let newLayer = updateColumnParam({ + layer, + columnId, + paramName: 'ignoreTimeRange', + value: !currentColumn.params.ignoreTimeRange, + }); + if ( + !currentColumn.params.ignoreTimeRange && + currentColumn.params.interval === autoInterval + ) { + const newFixedInterval = + data.search.aggs.calculateAutoTimeExpression({ + from: dateRange.fromDate, + to: dateRange.toDate, + }) || '1h'; + newLayer = updateColumnParam({ + layer: newLayer, + columnId, + paramName: 'interval', + value: newFixedInterval, + }); + setIntervalInput(newFixedInterval); + } + updateLayer(newLayer); + }} + compressed + /> +