diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 764fe35dd759d..1e4c82b5226df 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -23,7 +23,7 @@ pageLoadAssetSize: cloudSecurityPosture: 19109 console: 46091 contentManagement: 16254 - controls: 40000 + controls: 55082 core: 435325 crossClusterReplication: 65408 customIntegrations: 22034 diff --git a/src/plugins/controls/common/options_list/ip_search.ts b/src/plugins/controls/common/options_list/ip_search.ts index f371fbb1f6506..565c2ed2a1df6 100644 --- a/src/plugins/controls/common/options_list/ip_search.ts +++ b/src/plugins/controls/common/options_list/ip_search.ts @@ -17,6 +17,10 @@ interface IpSegments { type: 'ipv4' | 'ipv6' | 'unknown'; } +export const getIsValidFullIp = (searchString: string) => { + return ipaddr.IPv4.isValidFourPartDecimal(searchString) || ipaddr.IPv6.isValid(searchString); +}; + export const getIpSegments = (searchString: string): IpSegments => { if (searchString.indexOf('.') !== -1) { // ipv4 takes priority - so if search string contains both `.` and `:` then it will just be an invalid ipv4 search diff --git a/src/plugins/controls/common/options_list/is_valid_search.test.ts b/src/plugins/controls/common/options_list/is_valid_search.test.ts new file mode 100644 index 0000000000000..0334e1eb809e7 --- /dev/null +++ b/src/plugins/controls/common/options_list/is_valid_search.test.ts @@ -0,0 +1,118 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isValidSearch } from './is_valid_search'; + +describe('test validity of search strings', () => { + describe('number field', () => { + it('valid search - basic integer', () => { + expect(isValidSearch({ searchString: '123', fieldType: 'number' })).toBe(true); + }); + + it('valid search - floating point number', () => { + expect(isValidSearch({ searchString: '12.34', fieldType: 'number' })).toBe(true); + }); + + it('valid search - negative number', () => { + expect(isValidSearch({ searchString: '-42', fieldType: 'number' })).toBe(true); + }); + + it('invalid search - invalid character search string', () => { + expect(isValidSearch({ searchString: '1!a23', fieldType: 'number' })).toBe(false); + }); + }); + + // we do not currently support searching date fields, so they will always be invalid + describe('date field', () => { + it('invalid search - formatted date', () => { + expect(isValidSearch({ searchString: 'December 12, 2023', fieldType: 'date' })).toBe(false); + }); + + it('invalid search - invalid character search string', () => { + expect(isValidSearch({ searchString: '!!12/12/23?', fieldType: 'date' })).toBe(false); + }); + }); + + // only testing exact match validity here - the remainder of testing is covered by ./ip_search.test.ts + describe('ip field', () => { + it('valid search - ipv4', () => { + expect( + isValidSearch({ + searchString: '1.2.3.4', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(true); + }); + + it('valid search - full ipv6', () => { + expect( + isValidSearch({ + searchString: 'fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(true); + }); + + it('valid search - partial ipv6', () => { + expect( + isValidSearch({ + searchString: 'fbbe:a363::', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(true); + }); + + it('invalid search - invalid character search string', () => { + expect( + isValidSearch({ + searchString: '!!123.abc?', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(false); + }); + + it('invalid search - ipv4', () => { + expect( + isValidSearch({ + searchString: '1.2.3.256', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(false); + }); + + it('invalid search - ipv6', () => { + expect( + isValidSearch({ + searchString: '::fbbe:a363::', + fieldType: 'ip', + searchTechnique: 'exact', + }) + ).toBe(false); + }); + }); + + // string field searches can never be invalid + describe('string field', () => { + it('valid search - basic search string', () => { + expect(isValidSearch({ searchString: 'abc', fieldType: 'string' })).toBe(true); + }); + + it('valid search - numeric search string', () => { + expect(isValidSearch({ searchString: '123', fieldType: 'string' })).toBe(true); + }); + + it('valid search - complex search string', () => { + expect(isValidSearch({ searchString: '!+@abc*&[]', fieldType: 'string' })).toBe(true); + }); + }); +}); diff --git a/src/plugins/controls/common/options_list/is_valid_search.ts b/src/plugins/controls/common/options_list/is_valid_search.ts new file mode 100644 index 0000000000000..2d69271f991fc --- /dev/null +++ b/src/plugins/controls/common/options_list/is_valid_search.ts @@ -0,0 +1,52 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getIpRangeQuery, getIsValidFullIp } from './ip_search'; +import { OptionsListSearchTechnique } from './suggestions_searching'; + +/** + * ipaddr is a fairly large library - therefore, this function needs to be separate from + * the `suggestions_searching` file (which is used in the OptionsListEditorOptions component, + * which is in the factory and not async imported) + */ + +export const isValidSearch = ({ + searchString, + fieldType, + searchTechnique, +}: { + searchString?: string; + fieldType?: string; + searchTechnique?: OptionsListSearchTechnique; +}): boolean => { + if (!searchString || searchString.length === 0) return true; + + switch (fieldType) { + case 'number': { + return !isNaN(Number(searchString)); + } + case 'date': { + /** searching is not currently supported for date fields */ + return false; + } + case 'ip': { + if (searchTechnique === 'exact') { + /** + * exact match searching will throw an error if the search string isn't a **full** IP, + * so we need a slightly different validity check here than for other search techniques + */ + return getIsValidFullIp(searchString); + } + return getIpRangeQuery(searchString).validSearch; + } + default: { + /** string searches are always considered to be valid */ + return true; + } + } +}; diff --git a/src/plugins/controls/common/options_list/suggestions_searching.ts b/src/plugins/controls/common/options_list/suggestions_searching.ts new file mode 100644 index 0000000000000..a68788bba322e --- /dev/null +++ b/src/plugins/controls/common/options_list/suggestions_searching.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type OptionsListSearchTechnique = 'prefix' | 'wildcard' | 'exact'; + +export const getDefaultSearchTechnique = (type: string): OptionsListSearchTechnique | undefined => { + const compatibleSearchTechniques = getCompatibleSearchTechniques(type); + return compatibleSearchTechniques.length > 0 ? compatibleSearchTechniques[0] : undefined; +}; + +export const getCompatibleSearchTechniques = (type?: string): OptionsListSearchTechnique[] => { + switch (type) { + case 'string': { + return ['prefix', 'wildcard', 'exact']; + } + case 'ip': { + return ['prefix', 'exact']; + } + case 'number': { + return ['exact']; + } + default: { + return []; + } + } +}; diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index 460f7df080a88..d7f75d5c268c2 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -6,17 +6,15 @@ * Side Public License, v 1. */ -import { FieldSpec, DataView, RuntimeFieldSpec } from '@kbn/data-views-plugin/common'; -import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query'; +import { DataView, FieldSpec, RuntimeFieldSpec } from '@kbn/data-views-plugin/common'; +import type { BoolQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import type { OptionsListSortingType } from './suggestions_sorting'; import type { DataControlInput } from '../types'; +import { OptionsListSearchTechnique } from './suggestions_searching'; +import type { OptionsListSortingType } from './suggestions_sorting'; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; -export type OptionsListSearchTechnique = 'prefix' | 'wildcard'; -export const OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE: OptionsListSearchTechnique = 'prefix'; - export interface OptionsListEmbeddableInput extends DataControlInput { searchTechnique?: OptionsListSearchTechnique; sort?: OptionsListSortingType; diff --git a/src/plugins/controls/common/range_slider/mocks.tsx b/src/plugins/controls/common/range_slider/mocks.tsx index 049e882a32681..d656102c26cf9 100644 --- a/src/plugins/controls/common/range_slider/mocks.tsx +++ b/src/plugins/controls/common/range_slider/mocks.tsx @@ -5,8 +5,15 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { RangeSliderEmbeddableInput } from '..'; +import { + ControlFactory, + ControlOutput, + RangeSliderEmbeddable, + RangeSliderEmbeddableFactory, +} from '../../public'; +import * as rangeSliderStateModule from '../../public/range_slider/range_slider_reducers'; +import { RangeSliderComponentState } from '../../public/range_slider/types'; export const mockRangeSliderEmbeddableInput = { id: 'sample options list', @@ -14,3 +21,40 @@ export const mockRangeSliderEmbeddableInput = { dataViewId: 'sample id', value: ['0', '10'], } as RangeSliderEmbeddableInput; + +const mockRangeSliderComponentState = { + field: { name: 'bytes', type: 'number', aggregatable: true }, + min: undefined, + max: undefined, + error: undefined, + isInvalid: false, +} as RangeSliderComponentState; + +const mockRangeSliderOutput = { + loading: false, +} as ControlOutput; + +export const mockRangeSliderEmbeddable = async (partialState?: { + explicitInput?: Partial; + componentState?: Partial; +}) => { + const rangeSliderFactoryStub = new RangeSliderEmbeddableFactory(); + const rangeSliderControlFactory = rangeSliderFactoryStub as unknown as ControlFactory; + rangeSliderControlFactory.getDefaultInput = () => ({}); + + // initial component state can be provided by overriding the defaults. + const initialComponentState = { + ...mockRangeSliderComponentState, + ...partialState?.componentState, + }; + jest + .spyOn(rangeSliderStateModule, 'getDefaultComponentState') + .mockImplementation(() => initialComponentState); + + const mockEmbeddable = (await rangeSliderControlFactory.create({ + ...mockRangeSliderEmbeddableInput, + ...partialState?.explicitInput, + })) as RangeSliderEmbeddable; + mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockRangeSliderOutput); + return mockEmbeddable; +}; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 6de3797b2e9c4..651b0d6e4e317 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -7,6 +7,7 @@ */ import { i18n } from '@kbn/i18n'; +import { RANGE_SLIDER_CONTROL } from '../range_slider'; export const ControlGroupStrings = { manageControl: { @@ -35,10 +36,6 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.dataSource.dataViewTitle', { defaultMessage: 'Data view', }), - noControlTypeMessage: () => - i18n.translate('controls.controlGroup.manageControl.dataSource.noControlTypeMessage', { - defaultMessage: 'No field selected yet', - }), getFieldTitle: () => i18n.translate('controls.controlGroup.manageControl.dataSource.fieldTitle', { defaultMessage: 'Field', @@ -47,6 +44,46 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.dataSource.controlTypesTitle', { defaultMessage: 'Control type', }), + getControlTypeErrorMessage: ({ + fieldSelected, + controlType, + }: { + fieldSelected?: boolean; + controlType?: string; + }) => { + if (!fieldSelected) { + return i18n.translate( + 'controls.controlGroup.manageControl.dataSource.controlTypErrorMessage.noField', + { + defaultMessage: 'Select a field first.', + } + ); + } + + switch (controlType) { + /** + * Note that options list controls are currently compatible with every field type; so, there is no + * need to have a special error message for these. + */ + case RANGE_SLIDER_CONTROL: { + return i18n.translate( + 'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.rangeSlider', + { + defaultMessage: 'Range sliders are only compatible with number fields.', + } + ); + } + default: { + /** This shouldn't ever happen - but, adding just in case as a fallback. */ + return i18n.translate( + 'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.default', + { + defaultMessage: 'Select a compatible control type.', + } + ); + } + } + }, }, displaySettings: { getFormGroupTitle: () => @@ -231,6 +268,7 @@ export const ControlGroupStrings = { 'Selections in one control narrow down available options in the next. Controls are chained from left to right.', }), }, + /** TODO: These translations aren't used but they will be once https://github.com/elastic/kibana/issues/162985 is resolved */ querySync: { getQuerySettingsTitle: () => i18n.translate('controls.controlGroup.management.query.searchSettingsTitle', { diff --git a/src/plugins/controls/public/control_group/editor/control_editor.test.tsx b/src/plugins/controls/public/control_group/editor/control_editor.test.tsx index 33c627df20460..d6e079261e183 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.test.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.test.tsx @@ -6,29 +6,33 @@ * Side Public License, v 1. */ -import React from 'react'; import { ReactWrapper } from 'enzyme'; +import React from 'react'; import { act } from 'react-dom/test-utils'; -import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; -import { ControlGroupInput } from '../types'; -import { pluginServices } from '../../services'; -import { ControlEditor, EditControlProps } from './control_editor'; import { OptionsListEmbeddableFactory } from '../..'; +import { + OptionsListEmbeddableInput, + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, +} from '../../../common'; import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH, } from '../../../common/control_group/control_group_constants'; -import { mockControlGroupContainer, mockOptionsListEmbeddable } from '../../../common/mocks'; +import { + mockControlGroupContainer, + mockOptionsListEmbeddable, + mockRangeSliderEmbeddable, +} from '../../../common/mocks'; import { RangeSliderEmbeddableFactory } from '../../range_slider'; +import { pluginServices } from '../../services'; import { ControlGroupContainerContext } from '../embeddable/control_group_container'; -import { - OptionsListEmbeddableInput, - OPTIONS_LIST_CONTROL, - RANGE_SLIDER_CONTROL, -} from '../../../common'; +import { ControlGroupInput } from '../types'; +import { ControlEditor, EditControlProps } from './control_editor'; describe('Data control editor', () => { interface MountOptions { @@ -96,10 +100,13 @@ describe('Data control editor', () => { await selectField('machine.os.raw'); }); - test('creates an options list control', async () => { - expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual( - 'Options list' - ); + test('can only create an options list control', async () => { + expect( + findTestSubject(controlEditor, 'create__optionsListControl').instance() + ).toBeEnabled(); + expect( + findTestSubject(controlEditor, 'create__rangeSliderControl').instance() + ).not.toBeEnabled(); }); test('has custom settings', async () => { @@ -113,6 +120,8 @@ describe('Data control editor', () => { 'optionsListControl__searchOptionsRadioGroup' ); expect(searchOptions.exists()).toBe(true); + const options = searchOptions.find('div.euiRadioGroup__item'); + expect(options.length).toBe(3); }); }); @@ -122,18 +131,23 @@ describe('Data control editor', () => { await selectField('clientip'); }); - test('creates an options list control', async () => { - expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual( - 'Options list' - ); + test('can only create an options list control', async () => { + expect( + findTestSubject(controlEditor, 'create__optionsListControl').instance() + ).toBeEnabled(); + expect( + findTestSubject(controlEditor, 'create__rangeSliderControl').instance() + ).not.toBeEnabled(); }); - test('does not have custom search options', async () => { + test('has custom search options', async () => { const searchOptions = findTestSubject( controlEditor, 'optionsListControl__searchOptionsRadioGroup' ); - expect(searchOptions.exists()).toBe(false); + expect(searchOptions.exists()).toBe(true); + const options = searchOptions.find('div.euiRadioGroup__item'); + expect(options.length).toBe(2); }); }); @@ -143,13 +157,38 @@ describe('Data control editor', () => { await selectField('bytes'); }); - test('creates a range slider control', async () => { - expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual( - 'Range slider' + test('can create an options list or range slider control', async () => { + expect( + findTestSubject(controlEditor, 'create__optionsListControl').instance() + ).toBeEnabled(); + expect( + findTestSubject(controlEditor, 'create__rangeSliderControl').instance() + ).toBeEnabled(); + }); + + test('defaults to options list creation', async () => { + expect( + findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed') + ).toBe(true); + }); + + test('when creating options list, has custom settings', async () => { + findTestSubject(controlEditor, 'create__optionsListControl').simulate('click'); + const customSettings = findTestSubject(controlEditor, 'control-editor-custom-settings'); + expect(customSettings.exists()).toBe(true); + }); + + test('when creating options list, does not have custom search options', async () => { + findTestSubject(controlEditor, 'create__optionsListControl').simulate('click'); + const searchOptions = findTestSubject( + controlEditor, + 'optionsListControl__searchOptionsRadioGroup' ); + expect(searchOptions.exists()).toBe(false); }); - test('does not have any custom settings', async () => { + test('when creating range slider, does not have custom settings', async () => { + findTestSubject(controlEditor, 'create__rangeSliderControl').simulate('click'); const searchOptions = findTestSubject(controlEditor, 'control-editor-custom-settings'); expect(searchOptions.exists()).toBe(false); }); @@ -174,15 +213,28 @@ describe('Data control editor', () => { }); describe('editing existing options list control', () => { - const openOptionsListEditor = async (explicitInput?: Partial) => { - const control = await mockOptionsListEmbeddable({ - explicitInput: { - title: 'machine.os.raw', - dataViewId: stubDataView.id, - fieldName: 'machine.os.raw', - ...explicitInput, - }, - }); + const openEditor = async ( + type: string, + explicitInput?: Partial + ) => { + const control = + type === 'optionsList' + ? await mockOptionsListEmbeddable({ + explicitInput: { + title: 'machine.os.raw', + dataViewId: stubDataView.id, + fieldName: 'machine.os.raw', + ...explicitInput, + }, + }) + : await mockRangeSliderEmbeddable({ + explicitInput: { + title: 'bytes', + dataViewId: stubDataView.id, + fieldName: 'bytes', + ...explicitInput, + }, + }); await mountComponent({ componentOptions: { isCreate: false, embeddable: control }, }); @@ -190,23 +242,45 @@ describe('Data control editor', () => { describe('control title', () => { test('auto-fills default', async () => { - await openOptionsListEditor(); + await openEditor('optionsList'); const titleInput = findTestSubject(controlEditor, 'control-editor-title-input'); expect(titleInput.prop('value')).toBe('machine.os.raw'); expect(titleInput.prop('placeholder')).toBe('machine.os.raw'); }); test('auto-fills custom title', async () => { - await openOptionsListEditor({ title: 'Custom Title' }); + await openEditor('optionsList', { title: 'Custom Title' }); const titleInput = findTestSubject(controlEditor, 'control-editor-title-input'); expect(titleInput.prop('value')).toBe('Custom Title'); expect(titleInput.prop('placeholder')).toBe('machine.os.raw'); }); }); + describe('control type', () => { + test('selects the default control type', async () => { + await openEditor('optionsList', { fieldName: 'bytes' }); + expect( + findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed') + ).toBe(true); + expect( + findTestSubject(controlEditor, 'create__rangeSliderControl').prop('aria-pressed') + ).toBe(false); + }); + + test('selects the given, non-default control type', async () => { + await openEditor('rangeSlider', { fieldName: 'bytes' }); + expect( + findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed') + ).toBe(false); + expect( + findTestSubject(controlEditor, 'create__rangeSliderControl').prop('aria-pressed') + ).toBe(true); + }); + }); + describe('selection options', () => { test('selects default', async () => { - await openOptionsListEditor(); + await openEditor('optionsList'); const radioGroup = findTestSubject( controlEditor, 'optionsListControl__selectionOptionsRadioGroup' @@ -216,7 +290,7 @@ describe('Data control editor', () => { }); test('selects given', async () => { - await openOptionsListEditor({ singleSelect: true }); + await openEditor('optionsList', { singleSelect: true }); const radioGroup = findTestSubject( controlEditor, 'optionsListControl__selectionOptionsRadioGroup' @@ -228,7 +302,7 @@ describe('Data control editor', () => { describe('search techniques', () => { test('selects default', async () => { - await openOptionsListEditor(); + await openEditor('optionsList'); const radioGroup = findTestSubject( controlEditor, 'optionsListControl__searchOptionsRadioGroup' @@ -238,7 +312,7 @@ describe('Data control editor', () => { }); test('selects given', async () => { - await openOptionsListEditor({ searchTechnique: 'wildcard' }); + await openEditor('optionsList', { searchTechnique: 'wildcard' }); const radioGroup = findTestSubject( controlEditor, 'optionsListControl__searchOptionsRadioGroup' diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index 0860f19e506e3..aa9f505075426 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,29 +14,31 @@ * Side Public License, v 1. */ +import deepEqual from 'fast-deep-equal'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import useMount from 'react-use/lib/useMount'; import useAsync from 'react-use/lib/useAsync'; -import deepEqual from 'fast-deep-equal'; +import useMount from 'react-use/lib/useMount'; import { - EuiFlyoutHeader, + EuiButton, + EuiButtonEmpty, EuiButtonGroup, - EuiFlyoutBody, + EuiDescribedFormGroup, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiTitle, - EuiFieldText, + EuiFlyoutBody, EuiFlyoutFooter, - EuiButton, - EuiFormRow, + EuiFlyoutHeader, EuiForm, - EuiButtonEmpty, - EuiSpacer, + EuiFormRow, EuiIcon, + EuiKeyPadMenu, + EuiKeyPadMenuItem, + EuiSpacer, EuiSwitch, - EuiTextColor, - EuiDescribedFormGroup, + EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { @@ -45,7 +47,8 @@ import { withSuspense, } from '@kbn/presentation-util-plugin/public'; -import { ControlGroupStrings } from '../control_group_strings'; +import { TIME_SLIDER_CONTROL } from '../../../common'; +import { pluginServices } from '../../services'; import { ControlEmbeddable, ControlInput, @@ -54,10 +57,10 @@ import { DataControlInput, IEditableControlFactory, } from '../../types'; -import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; -import { pluginServices } from '../../services'; -import { getDataControlFieldRegistry } from './data_control_editor_tools'; +import { ControlGroupStrings } from '../control_group_strings'; import { useControlGroupContainer } from '../embeddable/control_group_container'; +import { getDataControlFieldRegistry } from './data_control_editor_tools'; +import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; export interface EditControlProps { embeddable?: ControlEmbeddable; @@ -87,7 +90,7 @@ export const ControlEditor = ({ }: EditControlProps) => { const { dataViews: { getIdsWithTitle, getDefaultId, get }, - controls: { getControlFactory }, + controls: { getControlFactory, getControlTypes }, } = pluginServices.getServices(); const controlGroup = useControlGroupContainer(); @@ -102,6 +105,9 @@ export const ControlEditor = ({ const [selectedField, setSelectedField] = useState( embeddable ? embeddable.getInput().fieldName : undefined ); + const [selectedControlType, setSelectedControlType] = useState( + embeddable ? embeddable.type : undefined + ); const [customSettings, setCustomSettings] = useState>(); const currentInput: Partial = useMemo( @@ -157,15 +163,93 @@ export const ControlEditor = ({ }, [selectedDataViewId]); useEffect( - () => setControlEditorValid(Boolean(selectedField) && Boolean(selectedDataView)), - [selectedField, setControlEditorValid, selectedDataView] + () => + setControlEditorValid( + Boolean(selectedField) && Boolean(selectedDataView) && Boolean(selectedControlType) + ), + [selectedField, setControlEditorValid, selectedDataView, selectedControlType] ); - const controlType = - selectedField && fieldRegistry && fieldRegistry[selectedField].compatibleControlTypes[0]; - const factory = controlType && getControlFactory(controlType); - const CustomSettings = - factory && (factory as IEditableControlFactory).controlEditorOptionsComponent; + const CompatibleControlTypesComponent = useMemo(() => { + const allDataControlTypes = getControlTypes().filter((type) => type !== TIME_SLIDER_CONTROL); + return ( + + {allDataControlTypes.map((controlType) => { + const factory = getControlFactory(controlType); + + const disabled = + fieldRegistry && selectedField + ? !fieldRegistry[selectedField].compatibleControlTypes.includes(controlType) + : true; + const keyPadMenuItem = ( + setSelectedControlType(controlType)} + label={factory.getDisplayName()} + > + + + ); + + return disabled ? ( + + {keyPadMenuItem} + + ) : ( + keyPadMenuItem + ); + })} + + ); + }, [selectedField, fieldRegistry, getControlFactory, getControlTypes, selectedControlType]); + + const CustomSettingsComponent = useMemo(() => { + if (!selectedControlType || !selectedField || !fieldRegistry) return; + + const controlFactory = getControlFactory(selectedControlType); + const CustomSettings = (controlFactory as IEditableControlFactory) + .controlEditorOptionsComponent; + + if (!CustomSettings) return; + + return ( + + {ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupTitle( + controlFactory.getDisplayName() + )} + + } + description={ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupDescription( + controlFactory.getDisplayName() + )} + data-test-subj="control-editor-custom-settings" + > + setCustomSettings(settings)} + initialInput={embeddable?.getInput()} + fieldType={fieldRegistry[selectedField].field.type} + /> + + ); + }, [selectedControlType, selectedField, getControlFactory, fieldRegistry, embeddable]); + return ( <> @@ -216,6 +300,9 @@ export const ControlEditor = ({ const newDefaultTitle = field.displayName ?? field.name; setDefaultTitle(newDefaultTitle); setSelectedField(field.name); + setSelectedControlType( + fieldRegistry?.[field.displayName].compatibleControlTypes[0] + ); if (!currentTitle || currentTitle === defaultTitle) { setCurrentTitle(newDefaultTitle); } @@ -224,20 +311,7 @@ export const ControlEditor = ({ /> - {factory ? ( - - - - - - {factory.getDisplayName()} - - - ) : ( - - {ControlGroupStrings.manageControl.dataSource.noControlTypeMessage()} - - )} + {CompatibleControlTypesComponent} )} - {!editorConfig?.hideAdditionalSettings && - CustomSettings && - (factory as IEditableControlFactory).controlEditorOptionsComponent && ( - - {ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupTitle( - factory.getDisplayName() - )} - - } - description={ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupDescription( - factory.getDisplayName() - )} - data-test-subj="control-editor-custom-settings" - > - setCustomSettings(settings)} - initialInput={embeddable?.getInput()} - fieldType={fieldRegistry?.[selectedField].field.type} - /> - - )} + {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} {removeControl && ( <> @@ -350,7 +401,10 @@ export const ControlEditor = ({ color="primary" disabled={!controlEditorValid} onClick={() => - onSave({ input: currentInput, grow: currentGrow, width: currentWidth }, controlType) + onSave( + { input: currentInput, grow: currentGrow, width: currentWidth }, + selectedControlType + ) } > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx b/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx index 403a2b941a586..74e8d61ed218c 100644 --- a/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx @@ -6,33 +6,33 @@ * Side Public License, v 1. */ +import React, { useEffect, useMemo, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import React, { useEffect, useState } from 'react'; import { + Direction, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, - EuiSwitch, - Direction, - EuiRadioGroup, EuiLoadingSpinner, + EuiRadioGroup, + EuiSwitch, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { pluginServices } from '../../services'; +import { ControlEditorProps, OptionsListEmbeddableInput } from '../..'; +import { + getCompatibleSearchTechniques, + OptionsListSearchTechnique, +} from '../../../common/options_list/suggestions_searching'; import { - OptionsListSortBy, getCompatibleSortingTypes, + OptionsListSortBy, OPTIONS_LIST_DEFAULT_SORT, } from '../../../common/options_list/suggestions_sorting'; +import { pluginServices } from '../../services'; import { OptionsListStrings } from './options_list_strings'; -import { ControlEditorProps, OptionsListEmbeddableInput } from '../..'; -import { - OptionsListSearchTechnique, - OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE, -} from '../../../common/options_list/types'; const TooltipText = ({ label, tooltip }: { label: string; tooltip: string }) => ( @@ -61,7 +61,7 @@ const selectionOptions = [ }, ]; -const searchOptions = [ +const allSearchOptions = [ { id: 'prefix', label: ( @@ -82,6 +82,16 @@ const searchOptions = [ ), 'data-test-subj': 'optionsListControl__wildcardSearchOptionAdditionalSetting', }, + { + id: 'exact', + label: ( + + ), + 'data-test-subj': 'optionsListControl__exactSearchOptionAdditionalSetting', + }, ]; interface OptionsListEditorState { @@ -117,6 +127,17 @@ export const OptionsListEditorOptions = ({ return optionsListService.getAllowExpensiveQueries(); }, []); + const compatibleSearchTechniques = useMemo( + () => getCompatibleSearchTechniques(fieldType), + [fieldType] + ); + + const searchOptions = useMemo(() => { + return allSearchOptions.filter((searchOption) => { + return compatibleSearchTechniques.includes(searchOption.id as OptionsListSearchTechnique); + }); + }, [compatibleSearchTechniques]); + useEffect(() => { // when field type changes, ensure that the selected sort type is still valid if (!getCompatibleSortingTypes(fieldType).includes(state.sortBy)) { @@ -129,6 +150,21 @@ export const OptionsListEditorOptions = ({ } }, [fieldType, onChange, state.sortBy]); + useEffect(() => { + // when field type changes, ensure that the selected search technique is still valid; + // if the selected search technique **isn't** valid, reset to the default + const searchTechnique = + initialInput?.searchTechnique && + compatibleSearchTechniques.includes(initialInput.searchTechnique) + ? initialInput.searchTechnique + : compatibleSearchTechniques[0]; + onChange({ searchTechnique }); + setState((s) => ({ + ...s, + searchTechnique, + })); + }, [compatibleSearchTechniques, onChange, initialInput]); + return ( <> ) : ( allowExpensiveQueries && - !['ip', 'date'].includes(fieldType) && ( + compatibleSearchTechniques.length > 1 && ( { const searchTechnique = id as OptionsListSearchTechnique; onChange({ searchTechnique }); diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx index e9af654ac75eb..59f1c9f2b058c 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx @@ -67,241 +67,269 @@ describe('Options list popover', () => { expect(noOptionsDiv.exists()).toBeTruthy(); }); - test('display error message when the show only selected toggle is true but there are no selections', async () => { - const popover = await mountComponent(); - clickShowOnlySelections(popover); - const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - const noSelectionsDiv = findTestSubject( - availableOptionsDiv, - 'optionsList-control-selectionsEmptyMessage' - ); - expect(noSelectionsDiv.exists()).toBeTruthy(); - }); - - test('show only selected options', async () => { - const selections = ['woof', 'bark']; - const popover = await mountComponent({ - explicitInput: { selectedOptions: selections }, + describe('show only selected', () => { + test('display error message when the show only selected toggle is true but there are no selections', async () => { + const popover = await mountComponent(); + clickShowOnlySelections(popover); + const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + const noSelectionsDiv = findTestSubject( + availableOptionsDiv, + 'optionsList-control-selectionsEmptyMessage' + ); + expect(noSelectionsDiv.exists()).toBeTruthy(); }); - clickShowOnlySelections(popover); - const availableOptions = popover.find( - '[data-test-subj="optionsList-control-available-options"] ul' - ); - availableOptions.children().forEach((child, i) => { - expect(child.text()).toBe(`${selections[i]}. Checked option.`); - }); - }); - test('disable search and sort when show only selected toggle is true', async () => { - const selections = ['woof', 'bark']; - const popover = await mountComponent({ - explicitInput: { selectedOptions: selections }, + test('show only selected options', async () => { + const selections = ['woof', 'bark']; + const popover = await mountComponent({ + explicitInput: { selectedOptions: selections }, + }); + clickShowOnlySelections(popover); + const availableOptions = popover.find( + '[data-test-subj="optionsList-control-available-options"] ul' + ); + availableOptions.children().forEach((child, i) => { + expect(child.text()).toBe(`${selections[i]}. Checked option.`); + }); }); - let searchBox = findTestSubject(popover, 'optionsList-control-search-input'); - let sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - expect(searchBox.prop('disabled')).toBeFalsy(); - expect(sortButton.prop('disabled')).toBeFalsy(); - - clickShowOnlySelections(popover); - searchBox = findTestSubject(popover, 'optionsList-control-search-input'); - sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - expect(searchBox.prop('disabled')).toBe(true); - expect(sortButton.prop('disabled')).toBe(true); - }); - test('test single invalid selection', async () => { - const popover = await mountComponent({ - explicitInput: { - selectedOptions: ['bark', 'woof'], - }, - componentState: { - availableOptions: [{ value: 'bark', docCount: 75 }], - validSelections: ['bark'], - invalidSelections: ['woof'], - }, + test('disable search and sort when show only selected toggle is true', async () => { + const selections = ['woof', 'bark']; + const popover = await mountComponent({ + explicitInput: { selectedOptions: selections }, + componentState: { field: { type: 'string' } as any as FieldSpec }, + }); + let searchBox = findTestSubject(popover, 'optionsList-control-search-input'); + let sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + expect(searchBox.prop('disabled')).toBeFalsy(); + expect(sortButton.prop('disabled')).toBeFalsy(); + + clickShowOnlySelections(popover); + searchBox = findTestSubject(popover, 'optionsList-control-search-input'); + sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + expect(searchBox.prop('disabled')).toBe(true); + expect(sortButton.prop('disabled')).toBe(true); }); - const validSelection = findTestSubject(popover, 'optionsList-control-selection-bark'); - expect(validSelection.find('.euiSelectableListItem__text').text()).toEqual( - 'bark. Checked option.' - ); - expect( - validSelection.find('div[data-test-subj="optionsList-document-count-badge"]').text().trim() - ).toEqual('75'); - const title = findTestSubject(popover, 'optionList__ignoredSelectionLabel').text(); - expect(title).toEqual('Ignored selection'); - const invalidSelection = findTestSubject(popover, 'optionsList-control-ignored-selection-woof'); - expect(invalidSelection.find('.euiSelectableListItem__text').text()).toEqual( - 'woof. Checked option.' - ); - expect(invalidSelection.hasClass('optionsList__selectionInvalid')).toBe(true); }); - test('test title when multiple invalid selections', async () => { - const popover = await mountComponent({ - explicitInput: { selectedOptions: ['bark', 'woof', 'meow'] }, - componentState: { - availableOptions: [{ value: 'bark', docCount: 75 }], - validSelections: ['bark'], - invalidSelections: ['woof', 'meow'], - }, + describe('invalid selections', () => { + test('test single invalid selection', async () => { + const popover = await mountComponent({ + explicitInput: { + selectedOptions: ['bark', 'woof'], + }, + componentState: { + availableOptions: [{ value: 'bark', docCount: 75 }], + validSelections: ['bark'], + invalidSelections: ['woof'], + }, + }); + const validSelection = findTestSubject(popover, 'optionsList-control-selection-bark'); + expect(validSelection.find('.euiSelectableListItem__text').text()).toEqual( + 'bark. Checked option.' + ); + expect( + validSelection.find('div[data-test-subj="optionsList-document-count-badge"]').text().trim() + ).toEqual('75'); + const title = findTestSubject(popover, 'optionList__ignoredSelectionLabel').text(); + expect(title).toEqual('Ignored selection'); + const invalidSelection = findTestSubject( + popover, + 'optionsList-control-ignored-selection-woof' + ); + expect(invalidSelection.find('.euiSelectableListItem__text').text()).toEqual( + 'woof. Checked option.' + ); + expect(invalidSelection.hasClass('optionsList__selectionInvalid')).toBe(true); }); - const title = findTestSubject(popover, 'optionList__ignoredSelectionLabel').text(); - expect(title).toEqual('Ignored selections'); - }); - test('should default to exclude = false', async () => { - const popover = await mountComponent(); - const includeButton = findTestSubject(popover, 'optionsList__includeResults'); - const excludeButton = findTestSubject(popover, 'optionsList__excludeResults'); - expect(includeButton.prop('aria-pressed')).toBe(true); - expect(excludeButton.prop('aria-pressed')).toBe(false); - }); - - test('if exclude = true, select appropriate button in button group', async () => { - const popover = await mountComponent({ - explicitInput: { exclude: true }, + test('test title when multiple invalid selections', async () => { + const popover = await mountComponent({ + explicitInput: { selectedOptions: ['bark', 'woof', 'meow'] }, + componentState: { + availableOptions: [{ value: 'bark', docCount: 75 }], + validSelections: ['bark'], + invalidSelections: ['woof', 'meow'], + }, + }); + const title = findTestSubject(popover, 'optionList__ignoredSelectionLabel').text(); + expect(title).toEqual('Ignored selections'); }); - const includeButton = findTestSubject(popover, 'optionsList__includeResults'); - const excludeButton = findTestSubject(popover, 'optionsList__excludeResults'); - expect(includeButton.prop('aria-pressed')).toBe(false); - expect(excludeButton.prop('aria-pressed')).toBe(true); }); - test('clicking another option unselects "Exists"', async () => { - const popover = await mountComponent({ - explicitInput: { existsSelected: true }, + describe('include/exclude toggle', () => { + test('should default to exclude = false', async () => { + const popover = await mountComponent(); + const includeButton = findTestSubject(popover, 'optionsList__includeResults'); + const excludeButton = findTestSubject(popover, 'optionsList__excludeResults'); + expect(includeButton.prop('aria-pressed')).toBe(true); + expect(excludeButton.prop('aria-pressed')).toBe(false); }); - const woofOption = findTestSubject(popover, 'optionsList-control-selection-woof'); - woofOption.simulate('click'); - const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - availableOptionsDiv.children().forEach((child, i) => { - if (child.text() === 'woof') expect(child.prop('aria-pressed')).toBe(true); - else expect(child.prop('aria-pressed')).toBeFalsy(); + test('if exclude = true, select appropriate button in button group', async () => { + const popover = await mountComponent({ + explicitInput: { exclude: true }, + }); + const includeButton = findTestSubject(popover, 'optionsList__includeResults'); + const excludeButton = findTestSubject(popover, 'optionsList__excludeResults'); + expect(includeButton.prop('aria-pressed')).toBe(false); + expect(excludeButton.prop('aria-pressed')).toBe(true); }); }); - test('clicking "Exists" unselects all other selections', async () => { - const selections = ['woof', 'bark']; - const popover = await mountComponent({ - explicitInput: { existsSelected: false, selectedOptions: selections }, - }); - const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); - let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - availableOptionsDiv.children().forEach((child, i) => { - if (selections.includes(child.text())) expect(child.prop('aria-pressed')).toBe(true); - else expect(child.prop('aria-pressed')).toBeFalsy(); + describe('"Exists" option', () => { + test('clicking another option unselects "Exists"', async () => { + const popover = await mountComponent({ + explicitInput: { existsSelected: true }, + }); + const woofOption = findTestSubject(popover, 'optionsList-control-selection-woof'); + woofOption.simulate('click'); + + const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (child.text() === 'woof') expect(child.prop('aria-pressed')).toBe(true); + else expect(child.prop('aria-pressed')).toBeFalsy(); + }); }); - existsOption.simulate('click'); - availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - availableOptionsDiv.children().forEach((child, i) => { - if (child.text() === 'Exists (*)') expect(child.prop('aria-pressed')).toBe(true); - else expect(child.prop('aria-pressed')).toBeFalsy(); + test('clicking "Exists" unselects all other selections', async () => { + const selections = ['woof', 'bark']; + const popover = await mountComponent({ + explicitInput: { existsSelected: false, selectedOptions: selections }, + }); + const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); + let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (selections.includes(child.text())) expect(child.prop('aria-pressed')).toBe(true); + else expect(child.prop('aria-pressed')).toBeFalsy(); + }); + + existsOption.simulate('click'); + availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (child.text() === 'Exists (*)') expect(child.prop('aria-pressed')).toBe(true); + else expect(child.prop('aria-pressed')).toBeFalsy(); + }); }); - }); - test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => { - const popover = await mountComponent({ - componentState: { availableOptions: [] }, - explicitInput: { existsSelected: false }, + test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => { + const popover = await mountComponent({ + componentState: { availableOptions: [] }, + explicitInput: { existsSelected: false }, + }); + const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); + expect(existsOption.exists()).toBeFalsy(); }); - const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); - expect(existsOption.exists()).toBeFalsy(); - }); - test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => { - const popover = await mountComponent({ - explicitInput: { existsSelected: true }, + test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => { + const popover = await mountComponent({ + explicitInput: { existsSelected: true }, + }); + clickShowOnlySelections(popover); + const availableOptions = popover.find( + '[data-test-subj="optionsList-control-available-options"] ul' + ); + expect(availableOptions.text()).toBe('Exists. Checked option.'); }); - clickShowOnlySelections(popover); - const availableOptions = popover.find( - '[data-test-subj="optionsList-control-available-options"] ul' - ); - expect(availableOptions.text()).toBe('Exists. Checked option.'); }); - test('when sorting suggestions, show both sorting types for keyword field', async () => { - const popover = await mountComponent({ - componentState: { - field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, - }, + describe('sorting suggestions', () => { + test('when sorting suggestions, show both sorting types for keyword field', async () => { + const popover = await mountComponent({ + componentState: { + field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, + }, + }); + const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + sortButton.simulate('click'); + + const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); + const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); + expect(optionsText).toEqual(['By document count. Checked option.', 'Alphabetically']); }); - const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - sortButton.simulate('click'); - const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); - const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); - expect(optionsText).toEqual(['By document count. Checked option.', 'Alphabetically']); - }); + test('sorting popover selects appropriate sorting type on load', async () => { + const popover = await mountComponent({ + explicitInput: { sort: { by: '_key', direction: 'asc' } }, + componentState: { + field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, + }, + }); + const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + sortButton.simulate('click'); - test('sorting popover selects appropriate sorting type on load', async () => { - const popover = await mountComponent({ - explicitInput: { sort: { by: '_key', direction: 'asc' } }, - componentState: { - field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, - }, - }); - const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - sortButton.simulate('click'); + const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); + const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); + expect(optionsText).toEqual(['By document count', 'Alphabetically. Checked option.']); - const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); - const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); - expect(optionsText).toEqual(['By document count', 'Alphabetically. Checked option.']); + const ascendingButton = findTestSubject(popover, 'optionsList__sortOrder_asc').instance(); + expect(ascendingButton).toHaveClass('euiButtonGroupButton-isSelected'); + const descendingButton = findTestSubject(popover, 'optionsList__sortOrder_desc').instance(); + expect(descendingButton).not.toHaveClass('euiButtonGroupButton-isSelected'); + }); - const ascendingButton = findTestSubject(popover, 'optionsList__sortOrder_asc').instance(); - expect(ascendingButton).toHaveClass('euiButtonGroupButton-isSelected'); - const descendingButton = findTestSubject(popover, 'optionsList__sortOrder_desc').instance(); - expect(descendingButton).not.toHaveClass('euiButtonGroupButton-isSelected'); - }); + test('when sorting suggestions, only show document count sorting for IP fields', async () => { + const popover = await mountComponent({ + componentState: { field: { name: 'Test IP field', type: 'ip' } as FieldSpec }, + }); + const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + sortButton.simulate('click'); - test('when sorting suggestions, only show document count sorting for IP fields', async () => { - const popover = await mountComponent({ - componentState: { field: { name: 'Test IP field', type: 'ip' } as FieldSpec }, + const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); + const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); + expect(optionsText).toEqual(['By document count. Checked option.']); }); - const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - sortButton.simulate('click'); - const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); - const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); - expect(optionsText).toEqual(['By document count. Checked option.']); - }); + test('when sorting suggestions, show "By date" sorting option for date fields', async () => { + const popover = await mountComponent({ + componentState: { field: { name: 'Test date field', type: 'date' } as FieldSpec }, + }); + const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + sortButton.simulate('click'); - test('when sorting suggestions, show "By date" sorting option for date fields', async () => { - const popover = await mountComponent({ - componentState: { field: { name: 'Test date field', type: 'date' } as FieldSpec }, + const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); + const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); + expect(optionsText).toEqual(['By document count. Checked option.', 'By date']); }); - const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); - sortButton.simulate('click'); - const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); - const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); - expect(optionsText).toEqual(['By document count. Checked option.', 'By date']); - }); + test('when sorting suggestions, show "Numerically" sorting option for number fields', async () => { + const popover = await mountComponent({ + componentState: { field: { name: 'Test number field', type: 'number' } as FieldSpec }, + }); + const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton'); + sortButton.simulate('click'); - test('ensure warning icon does not show up when testAllowExpensiveQueries = true/undefined', async () => { - const popover = await mountComponent({ - componentState: { field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec }, + const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions'); + const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim()); + expect(optionsText).toEqual(['By document count. Checked option.', 'Numerically']); }); - const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning'); - expect(warning).toEqual({}); }); - test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => { - pluginServices.getServices().optionsList.getAllowExpensiveQueries = jest.fn(() => - Promise.resolve(false) - ); - const popover = await mountComponent({ - componentState: { - field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, - allowExpensiveQueries: false, - }, + describe('allow expensive queries warning', () => { + test('ensure warning icon does not show up when testAllowExpensiveQueries = true/undefined', async () => { + const popover = await mountComponent({ + componentState: { field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec }, + }); + const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning'); + expect(warning).toEqual({}); + }); + + test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => { + pluginServices.getServices().optionsList.getAllowExpensiveQueries = jest.fn(() => + Promise.resolve(false) + ); + const popover = await mountComponent({ + componentState: { + field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec, + allowExpensiveQueries: false, + }, + }); + const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning'); + expect(warning.getDOMNode()).toBeInstanceOf(HTMLDivElement); }); - const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning'); - expect(warning.getDOMNode()).toBeInstanceOf(HTMLDivElement); }); - describe('Test advanced settings', () => { + describe('advanced settings', () => { const ensureComponentIsHidden = async ({ explicitInput, testSubject, diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx index 8e890350f4d40..a6754a50ecaff 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx @@ -6,22 +6,22 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { - EuiFieldSearch, EuiButtonIcon, + EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiToolTip, EuiText, + EuiToolTip, } from '@elastic/eui'; -import { OptionsListStrings } from './options_list_strings'; +import { getCompatibleSearchTechniques } from '../../../common/options_list/suggestions_searching'; import { useOptionsList } from '../embeddable/options_list_embeddable'; import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; -import { OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE } from '../../../common/options_list/types'; +import { OptionsListStrings } from './options_list_strings'; interface OptionsListPopoverProps { showOnlySelected: boolean; @@ -38,20 +38,29 @@ export const OptionsListPopoverActionBar = ({ const totalCardinality = optionsList.select((state) => state.componentState.totalCardinality) ?? 0; - const searchString = optionsList.select((state) => state.componentState.searchString); const fieldSpec = optionsList.select((state) => state.componentState.field); + const searchString = optionsList.select((state) => state.componentState.searchString); const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections); + const allowExpensiveQueries = optionsList.select( + (state) => state.componentState.allowExpensiveQueries + ); const hideSort = optionsList.select((state) => state.explicitInput.hideSort); const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique); - const allowExpensiveQueries = optionsList.select( - (state) => state.componentState.allowExpensiveQueries + const compatibleSearchTechniques = useMemo(() => { + if (!fieldSpec) return []; + return getCompatibleSearchTechniques(fieldSpec.type); + }, [fieldSpec]); + + const defaultSearchTechnique = useMemo( + () => searchTechnique ?? compatibleSearchTechniques[0], + [searchTechnique, compatibleSearchTechniques] ); return (
- {fieldSpec?.type !== 'date' && ( + {compatibleSearchTechniques.length > 0 && ( updateSearchString(event.target.value)} value={searchString.value} data-test-subj="optionsList-control-search-input" - placeholder={OptionsListStrings.popover.searchPlaceholder[ - searchTechnique ?? OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE - ].getPlaceholderText()} + placeholder={OptionsListStrings.popover.getSearchPlaceholder( + allowExpensiveQueries ? defaultSearchTechnique : 'exact' + )} /> )} diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx index 379a8da7040b4..c4e4918f97714 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx @@ -6,9 +6,11 @@ * Side Public License, v 1. */ -import React from 'react'; -import { EuiSelectableMessage, EuiIcon, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { EuiIcon, EuiSelectableMessage, EuiSpacer } from '@elastic/eui'; + +import { useOptionsList } from '../embeddable/options_list_embeddable'; import { OptionsListStrings } from './options_list_strings'; export const OptionsListPopoverEmptyMessage = ({ @@ -16,17 +18,35 @@ export const OptionsListPopoverEmptyMessage = ({ }: { showOnlySelected: boolean; }) => { + const optionsList = useOptionsList(); + + const searchString = optionsList.select((state) => state.componentState.searchString); + const fieldSpec = optionsList.select((state) => state.componentState.field); + const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique); + + const noResultsMessage = useMemo(() => { + if (showOnlySelected) { + return OptionsListStrings.popover.getSelectionsEmptyMessage(); + } + if (!searchString.valid && fieldSpec && searchTechnique) { + return OptionsListStrings.popover.getInvalidSearchMessage(fieldSpec.type); + } + return OptionsListStrings.popover.getEmptyMessage(); + }, [showOnlySelected, fieldSpec, searchString.valid, searchTechnique]); + return ( - + - {showOnlySelected - ? OptionsListStrings.popover.getSelectionsEmptyMessage() - : OptionsListStrings.popover.getEmptyMessage()} + {noResultsMessage} ); }; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx index 2b6dbdc3b9507..7ff64482b2c60 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx @@ -77,7 +77,7 @@ export const OptionsListPopoverInvalidSelections = () => { listProps={{ onFocusBadge: false, isVirtualized: false }} onChange={(newSuggestions, _, changedOption) => { setSelectableOptions(newSuggestions); - optionsList.dispatch.deselectOption(changedOption.label); + optionsList.dispatch.deselectOption(changedOption.key ?? changedOption.label); }} > {(list) => list} diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx index a7efc79825b86..097660a55a540 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx @@ -30,17 +30,21 @@ export const OptionsListPopoverSuggestions = ({ }: OptionsListPopoverSuggestionsProps) => { const optionsList = useOptionsList(); + const fieldSpec = optionsList.select((state) => state.componentState.field); const searchString = optionsList.select((state) => state.componentState.searchString); const availableOptions = optionsList.select((state) => state.componentState.availableOptions); const totalCardinality = optionsList.select((state) => state.componentState.totalCardinality); const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections); - const fieldSpec = optionsList.select((state) => state.componentState.field); + const allowExpensiveQueries = optionsList.select( + (state) => state.componentState.allowExpensiveQueries + ); const sort = optionsList.select((state) => state.explicitInput.sort); const fieldName = optionsList.select((state) => state.explicitInput.fieldName); const hideExists = optionsList.select((state) => state.explicitInput.hideExists); const singleSelect = optionsList.select((state) => state.explicitInput.singleSelect); const existsSelected = optionsList.select((state) => state.explicitInput.existsSelected); + const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique); const selectedOptions = optionsList.select((state) => state.explicitInput.selectedOptions); const dataViewId = optionsList.select((state) => state.output.dataViewId); @@ -52,11 +56,11 @@ export const OptionsListPopoverSuggestions = ({ const canLoadMoreSuggestions = useMemo( () => - totalCardinality + allowExpensiveQueries && searchString.valid && totalCardinality && !showOnlySelected ? (availableOptions ?? []).length < Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE) : false, - [availableOptions, totalCardinality] + [availableOptions, totalCardinality, searchString, showOnlySelected, allowExpensiveQueries] ); // track selectedOptions and invalidSelections in sets for more efficient lookup @@ -85,7 +89,7 @@ export const OptionsListPopoverSuggestions = ({ useEffect(() => { /* This useEffect makes selectableOptions responsive to search, show only selected, and clear selections */ const options: EuiSelectableOption[] = (suggestions ?? []).map((suggestion) => { - if (typeof suggestion === 'string') { + if (typeof suggestion !== 'object') { // this means that `showOnlySelected` is true, and doc count is not known when this is the case suggestion = { value: suggestion }; } @@ -145,6 +149,19 @@ export const OptionsListPopoverSuggestions = ({ } }, [loadMoreSuggestions, totalCardinality]); + const renderOption = useCallback( + (option, searchStringValue) => { + if (!allowExpensiveQueries || searchTechnique === 'exact') return option.label; + + return ( + + {option.label} + + ); + }, + [searchTechnique, allowExpensiveQueries] + ); + useEffect(() => { const container = listRef.current; if (!isLoading && canLoadMoreSuggestions) { @@ -166,13 +183,7 @@ export const OptionsListPopoverSuggestions = ({
{ - return ( - - {option.label} - - ); - }} + renderOption={(option) => renderOption(option, searchString.value)} listProps={{ onFocusBadge: false }} aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel( fieldName, diff --git a/src/plugins/controls/public/options_list/components/options_list_strings.ts b/src/plugins/controls/public/options_list/components/options_list_strings.ts index 2977bb9f1cbb3..1e1401918f61d 100644 --- a/src/plugins/controls/public/options_list/components/options_list_strings.ts +++ b/src/plugins/controls/public/options_list/components/options_list_strings.ts @@ -7,11 +7,12 @@ */ import { i18n } from '@kbn/i18n'; +import { OptionsListSearchTechnique } from '../../../common/options_list/suggestions_searching'; export const OptionsListStrings = { control: { getSeparator: (type?: string) => { - if (type === 'date') { + if (['date', 'number'].includes(type ?? '')) { return i18n.translate('controls.optionsList.control.dateSeparator', { defaultMessage: '; ', }); @@ -78,6 +79,17 @@ export const OptionsListStrings = { 'Matches values that contain the given search string. Results might take longer to populate.', }), }, + exact: { + getLabel: () => + i18n.translate('controls.optionsList.editor.exactSearchLabel', { + defaultMessage: 'Exact', + }), + getTooltip: () => + i18n.translate('controls.optionsList.editor.exactSearchTooltip', { + defaultMessage: + 'Matches values that are equal to the given search string. Returns results quickly.', + }), + }, }, getAdditionalSettingsTitle: () => i18n.translate('controls.optionsList.editor.additionalSettingsTitle', { @@ -127,6 +139,26 @@ export const OptionsListStrings = { i18n.translate('controls.optionsList.popover.selectionsEmpty', { defaultMessage: 'You have no selections', }), + getInvalidSearchMessage: (fieldType: string) => { + switch (fieldType) { + case 'ip': { + return i18n.translate('controls.optionsList.popover.invalidSearch.ip', { + defaultMessage: 'Your search is not a valid IP address.', + }); + } + case 'number': { + return i18n.translate('controls.optionsList.popover.invalidSearch.number', { + defaultMessage: 'Your search is not a valid number.', + }); + } + default: { + // this shouldn't happen, but giving a fallback error message just in case + return i18n.translate('controls.optionsList.popover.invalidSearch.invalidCharacters', { + defaultMessage: 'Your search contains invalid characters.', + }); + } + } + }, getAllOptionsButtonTitle: () => i18n.translate('controls.optionsList.popover.allOptionsTitle', { defaultMessage: 'Show all options', @@ -135,19 +167,24 @@ export const OptionsListStrings = { i18n.translate('controls.optionsList.popover.selectedOptionsTitle', { defaultMessage: 'Show only selected options', }), - searchPlaceholder: { - prefix: { - getPlaceholderText: () => - i18n.translate('controls.optionsList.popover.prefixSearchPlaceholder', { + getSearchPlaceholder: (searchTechnique?: OptionsListSearchTechnique) => { + switch (searchTechnique) { + case 'prefix': { + return i18n.translate('controls.optionsList.popover.prefixSearchPlaceholder', { defaultMessage: 'Starts with...', - }), - }, - wildcard: { - getPlaceholderText: () => - i18n.translate('controls.optionsList.popover.wildcardSearchPlaceholder', { + }); + } + case 'wildcard': { + return i18n.translate('controls.optionsList.popover.wildcardSearchPlaceholder', { defaultMessage: 'Contains...', - }), - }, + }); + } + case 'exact': { + return i18n.translate('controls.optionsList.popover.exactSearchPlaceholder', { + defaultMessage: 'Equals...', + }); + } + } }, getCardinalityLabel: (totalOptions: number) => i18n.translate('controls.optionsList.popover.cardinalityLabel', { @@ -234,14 +271,22 @@ export const OptionsListStrings = { }), }, _key: { - getSortByLabel: (type?: string) => - type === 'date' - ? i18n.translate('controls.optionsList.popover.sortBy.date', { + getSortByLabel: (type?: string) => { + switch (type) { + case 'date': + return i18n.translate('controls.optionsList.popover.sortBy.date', { defaultMessage: 'By date', - }) - : i18n.translate('controls.optionsList.popover.sortBy.alphabetical', { + }); + case 'number': + return i18n.translate('controls.optionsList.popover.sortBy.numeric', { + defaultMessage: 'Numerically', + }); + default: + return i18n.translate('controls.optionsList.popover.sortBy.alphabetical', { defaultMessage: 'Alphabetically', - }), + }); + } + }, }, }, sortOrder: { diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx index a3fecb46442a1..395499bb882c8 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx @@ -62,7 +62,7 @@ export class OptionsListEmbeddableFactory return ( !field.spec.scripted && field.aggregatable && - ['string', 'boolean', 'ip', 'date'].includes(field.type) + ['string', 'boolean', 'ip', 'date', 'number'].includes(field.type) ); }; diff --git a/src/plugins/controls/public/options_list/options_list_reducers.ts b/src/plugins/controls/public/options_list/options_list_reducers.ts index 9791e91198e0b..3300072c089f9 100644 --- a/src/plugins/controls/public/options_list/options_list_reducers.ts +++ b/src/plugins/controls/public/options_list/options_list_reducers.ts @@ -8,15 +8,15 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; -import { Filter } from '@kbn/es-query'; import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { Filter } from '@kbn/es-query'; -import { OptionsListReduxState, OptionsListComponentState } from './types'; -import { getIpRangeQuery } from '../../common/options_list/ip_search'; +import { isValidSearch } from '../../common/options_list/is_valid_search'; import { - OPTIONS_LIST_DEFAULT_SORT, OptionsListSortingType, + OPTIONS_LIST_DEFAULT_SORT, } from '../../common/options_list/suggestions_sorting'; +import { OptionsListComponentState, OptionsListReduxState } from './types'; export const getDefaultComponentState = (): OptionsListReduxState['componentState'] => ({ popoverOpen: false, @@ -36,12 +36,13 @@ export const optionsListReducers = { }, setSearchString: (state: WritableDraft, action: PayloadAction) => { state.componentState.searchString.value = action.payload; - if ( - action.payload !== '' && // empty string search is never invalid - state.componentState.field?.type === 'ip' // only IP searches can currently be invalid - ) { - state.componentState.searchString.valid = getIpRangeQuery(action.payload).validSearch; - } + state.componentState.searchString.valid = isValidSearch({ + searchString: action.payload, + fieldType: state.componentState.field?.type, + searchTechnique: state.componentState.allowExpensiveQueries + ? state.explicitInput.searchTechnique + : 'exact', // only exact match searching is supported when allowExpensiveQueries is false + }); }, setAllowExpensiveQueries: ( state: WritableDraft, diff --git a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts deleted file mode 100644 index d364027a55d9b..0000000000000 --- a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { FieldSpec } from '@kbn/data-views-plugin/common'; -import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -import { getCheapSuggestionAggregationBuilder } from './options_list_cheap_suggestion_queries'; -import { OptionsListRequestBody } from '../../common/options_list/types'; - -describe('options list cheap queries', () => { - let rawSearchResponseMock: SearchResponse = {} as SearchResponse; - - beforeEach(() => { - rawSearchResponseMock = { - hits: { - total: 10, - max_score: 10, - hits: [], - }, - took: 10, - timed_out: false, - _shards: { - failed: 0, - successful: 1, - total: 1, - skipped: 0, - }, - aggregations: {}, - }; - }); - - describe('suggestion aggregation', () => { - describe('keyword or text+keyword field', () => { - test('without a search string, creates keyword aggregation', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - allowExpensiveQueries: false, - fieldName: 'coolTestField.keyword', - sort: { by: '_count', direction: 'asc' }, - fieldSpec: { aggregatable: true } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "coolTestField.keyword", - "order": Object { - "_count": "asc", - }, - "shard_size": 10, - }, - }, - } - `); - }); - - test('with a search string, creates case sensitive keyword aggregation', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - searchString: 'cooool', - allowExpensiveQueries: false, - fieldName: 'coolTestField.keyword', - fieldSpec: { aggregatable: true } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "coolTestField.keyword", - "include": "cooool.*", - "order": Object { - "_count": "desc", - }, - "shard_size": 10, - }, - }, - } - `); - }); - }); - - test('creates nested aggregation for nested field', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - searchString: 'cooool', - allowExpensiveQueries: false, - fieldName: 'coolNestedField', - sort: { by: '_key', direction: 'asc' }, - fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "nestedSuggestions": Object { - "aggs": Object { - "suggestions": Object { - "terms": Object { - "field": "coolNestedField", - "include": "cooool.*", - "order": Object { - "_key": "asc", - }, - "shard_size": 10, - }, - }, - }, - "nested": Object { - "path": "path.to.nested", - }, - }, - } - `); - }); - - describe('boolean field', () => { - test('creates boolean aggregation for boolean field', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'coolean', - allowExpensiveQueries: false, - sort: { by: '_key', direction: 'desc' }, - fieldSpec: { type: 'boolean' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "coolean", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - }, - }, - } - `); - }); - }); - - describe('date field field', () => { - test('creates date aggregation for date field', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: '@timestamp', - allowExpensiveQueries: false, - sort: { by: '_key', direction: 'desc' }, - fieldSpec: { type: 'date' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "@timestamp", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - }, - }, - } - `); - }); - }); - - describe('IP field', () => { - test('without a search string, creates IP range aggregation with default range', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - allowExpensiveQueries: false, - sort: { by: '_count', direction: 'asc' }, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_count": "asc", - }, - "shard_size": 10, - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "from": "::", - "key": "ipv6", - "to": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - }, - ], - }, - }, - } - `); - }); - - test('full IPv4 in the search string, creates IP range aggregation with CIDR mask', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - allowExpensiveQueries: false, - searchString: '41.77.243.255', - sort: { by: '_key', direction: 'desc' }, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "key": "ipv4", - "mask": "41.77.243.255/32", - }, - ], - }, - }, - } - `); - }); - - test('full IPv6 in the search string, creates IP range aggregation with CIDR mask', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - allowExpensiveQueries: false, - sort: { by: '_key', direction: 'asc' }, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - searchString: 'f688:fb50:6433:bba2:604:f2c:194a:d3c5', - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_key": "asc", - }, - "shard_size": 10, - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "key": "ipv6", - "mask": "f688:fb50:6433:bba2:604:f2c:194a:d3c5/128", - }, - ], - }, - }, - } - `); - }); - - test('partial IPv4 in the search string, creates IP range aggregation with min and max', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - searchString: '41.77', - allowExpensiveQueries: false, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_count": "desc", - }, - "shard_size": 10, - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "from": "41.77.0.0", - "key": "ipv4", - "to": "41.77.255.255", - }, - ], - }, - }, - } - `); - }); - - test('partial IPv46 in the search string, creates IP range aggregation with min and max', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - searchString: 'cdb6:', - allowExpensiveQueries: false, - sort: { by: '_count', direction: 'desc' }, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_count": "desc", - }, - "shard_size": 10, - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "from": "cdb6::", - "key": "ipv6", - "to": "cdb6:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - }, - ], - }, - }, - } - `); - }); - }); - }); - - describe('suggestion parsing', () => { - test('parses keyword / text result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - searchString: 'cooool', - allowExpensiveQueries: false, - fieldName: 'coolTestField.keyword', - fieldSpec: { aggregatable: true } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 5, key: 'cool1' }, - { doc_count: 15, key: 'cool2' }, - { doc_count: 10, key: 'cool3' }, - ], - }, - }; - expect( - suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions - ).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 5, - "value": "cool1", - }, - Object { - "docCount": 15, - "value": "cool2", - }, - Object { - "docCount": 10, - "value": "cool3", - }, - ] - `); - }); - - test('parses boolean result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'coolean', - allowExpensiveQueries: false, - fieldSpec: { type: 'boolean' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 55, key_as_string: 'false' }, - { doc_count: 155, key_as_string: 'true' }, - ], - }, - }; - expect( - suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions - ).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 55, - "value": "false", - }, - Object { - "docCount": 155, - "value": "true", - }, - ] - `); - }); - - test('parses nested result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - searchString: 'cooool', - fieldName: 'coolNestedField', - allowExpensiveQueries: false, - fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - nestedSuggestions: { - suggestions: { - buckets: [ - { doc_count: 5, key: 'cool1' }, - { doc_count: 15, key: 'cool2' }, - { doc_count: 10, key: 'cool3' }, - ], - }, - }, - }; - expect( - suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions - ).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 5, - "value": "cool1", - }, - Object { - "docCount": 15, - "value": "cool2", - }, - Object { - "docCount": 10, - "value": "cool3", - }, - ] - `); - }); - - test('parses keyword only result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - searchString: 'cooool', - allowExpensiveQueries: false, - fieldName: 'coolTestField.keyword', - fieldSpec: { aggregatable: true } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 5, key: 'cool1' }, - { doc_count: 15, key: 'cool2' }, - { doc_count: 10, key: 'cool3' }, - ], - }, - }; - expect( - suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions - ).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 5, - "value": "cool1", - }, - Object { - "docCount": 15, - "value": "cool2", - }, - Object { - "docCount": 10, - "value": "cool3", - }, - ] - `); - }); - - test('parses mixed IPv4 and IPv6 result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'clientip', - allowExpensiveQueries: false, - fieldSpec: { type: 'ip' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: { - ipv4: { - from: '0.0.0.0', - to: '255.255.255.255', - filteredSuggestions: { - buckets: [ - { doc_count: 8, key: '21.35.91.62' }, - { doc_count: 8, key: '21.35.91.61' }, - { doc_count: 11, key: '111.52.174.2' }, - { doc_count: 1, key: '56.73.58.63' }, - { doc_count: 9, key: '23.216.241.120' }, - { doc_count: 10, key: '196.162.13.39' }, - { doc_count: 7, key: '203.88.33.151' }, - ], - }, - }, - ipv6: { - from: '::', - to: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', - filteredSuggestions: { - buckets: [ - { doc_count: 12, key: '52:ae76:5947:5e2a:551:fe6a:712a:c72' }, - { doc_count: 1, key: 'fd:4aa0:c27c:b04:997f:2de1:51b4:8418' }, - { doc_count: 9, key: '28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172' }, - { doc_count: 6, key: '1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8' }, - { doc_count: 10, key: 'f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63' }, - ], - }, - }, - }, - }, - }; - - const parsed = suggestionAggBuilder.parse( - rawSearchResponseMock, - optionsListRequestBodyMock - ).suggestions; - - expect(parsed).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 12, - "value": "52:ae76:5947:5e2a:551:fe6a:712a:c72", - }, - Object { - "docCount": 11, - "value": "111.52.174.2", - }, - Object { - "docCount": 10, - "value": "196.162.13.39", - }, - Object { - "docCount": 10, - "value": "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63", - }, - Object { - "docCount": 9, - "value": "23.216.241.120", - }, - Object { - "docCount": 9, - "value": "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172", - }, - Object { - "docCount": 8, - "value": "21.35.91.62", - }, - Object { - "docCount": 8, - "value": "21.35.91.61", - }, - Object { - "docCount": 7, - "value": "203.88.33.151", - }, - Object { - "docCount": 6, - "value": "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8", - }, - ] - `); - }); - - test('parses date result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: '@timestamp', - allowExpensiveQueries: false, - fieldSpec: { type: 'date' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getCheapSuggestionAggregationBuilder(optionsListRequestBodyMock); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 20, key: 1696824675 }, - { doc_count: 13, key: 1686086625 }, - { doc_count: 4, key: 1703684229 }, - { doc_count: 34, key: 1688603684 }, - ], - }, - }; - - const parsed = suggestionAggBuilder.parse( - rawSearchResponseMock, - optionsListRequestBodyMock - ).suggestions; - - expect(parsed).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 20, - "value": 1696824675, - }, - Object { - "docCount": 13, - "value": 1686086625, - }, - Object { - "docCount": 4, - "value": 1703684229, - }, - Object { - "docCount": 34, - "value": 1688603684, - }, - ] - `); - }); - }); -}); diff --git a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts deleted file mode 100644 index cba08877607d7..0000000000000 --- a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { get } from 'lodash'; -import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; - -import { OptionsListRequestBody, OptionsListSuggestions } from '../../common/options_list/types'; -import { getIpRangeQuery, type IpRangeQuery } from '../../common/options_list/ip_search'; -import { EsBucket, OptionsListSuggestionAggregationBuilder } from './types'; -import { - getEscapedRegexQuery, - getIpBuckets, - getSortType, -} from './options_list_suggestion_query_helpers'; - -/** - * Suggestion aggregations - */ -export const getCheapSuggestionAggregationBuilder = ({ fieldSpec }: OptionsListRequestBody) => { - if (fieldSpec?.type === 'boolean') { - return cheapSuggestionAggSubtypes.boolean; - } - if (fieldSpec?.type === 'ip') { - return cheapSuggestionAggSubtypes.ip; - } - if (fieldSpec && getFieldSubtypeNested(fieldSpec)) { - return cheapSuggestionAggSubtypes.subtypeNested; - } - return cheapSuggestionAggSubtypes.keywordOrText; -}; - -const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregationBuilder } = { - /** - * The "textOrKeyword" query / parser should be used whenever the field is built on some type non-nested string field - * (such as a keyword field or a keyword+text multi-field) - */ - keywordOrText: { - buildAggregation: ({ fieldName, fieldSpec, searchString, sort }: OptionsListRequestBody) => ({ - suggestions: { - terms: { - field: fieldName, - // disabling for date fields because applying a search string will return an error - ...(fieldSpec?.type !== 'date' && searchString && searchString.length > 0 - ? { include: `${getEscapedRegexQuery(searchString)}.*` } - : {}), - shard_size: 10, - order: getSortType(sort), - }, - }, - }), - parse: (rawEsResult) => ({ - suggestions: get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( - (acc: OptionsListSuggestions, suggestion: EsBucket) => { - acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); - return acc; - }, - [] - ), - }), - }, - - /** - * the "Boolean" query / parser should be used when the options list is built on a field of type boolean. The query is slightly different than a keyword query. - */ - boolean: { - buildAggregation: ({ fieldName, sort }: OptionsListRequestBody) => ({ - suggestions: { - terms: { - field: fieldName, - shard_size: 10, - order: getSortType(sort), - }, - }, - }), - parse: (rawEsResult) => ({ - suggestions: get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( - (acc: OptionsListSuggestions, suggestion: EsBucket & { key_as_string: string }) => { - acc.push({ value: suggestion.key_as_string, docCount: suggestion.doc_count }); - return acc; - }, - [] - ), - }), - }, - - /** - * the "IP" query / parser should be used when the options list is built on a field of type IP. - */ - ip: { - buildAggregation: ({ fieldName, searchString, sort }: OptionsListRequestBody) => { - let ipRangeQuery: IpRangeQuery = { - validSearch: true, - rangeQuery: [ - { - key: 'ipv6', - from: '::', - to: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', - }, - ], - }; - - if (searchString && searchString.length > 0) { - ipRangeQuery = getIpRangeQuery(searchString); - if (!ipRangeQuery.validSearch) { - // ideally should be prevented on the client side but, if somehow an invalid search gets through to the server, - // simply don't return an aggregation query for the ES search request - return undefined; - } - } - - return { - suggestions: { - ip_range: { - field: fieldName, - ranges: ipRangeQuery.rangeQuery, - keyed: true, - }, - aggs: { - filteredSuggestions: { - terms: { - field: fieldName, - shard_size: 10, - order: getSortType(sort), - }, - }, - }, - }, - }; - }, - parse: (rawEsResult, { sort }) => { - if (!Boolean(rawEsResult.aggregations?.suggestions)) { - // if this is happens, that means there is an invalid search that snuck through to the server side code; - // so, might as well early return with no suggestions - return { suggestions: [] }; - } - - const buckets: EsBucket[] = []; - getIpBuckets(rawEsResult, buckets, 'ipv4'); // modifies buckets array directly, i.e. "by reference" - getIpBuckets(rawEsResult, buckets, 'ipv6'); - - const sortedSuggestions = - sort?.direction === 'asc' - ? buckets.sort( - (bucketA: EsBucket, bucketB: EsBucket) => bucketA.doc_count - bucketB.doc_count - ) - : buckets.sort( - (bucketA: EsBucket, bucketB: EsBucket) => bucketB.doc_count - bucketA.doc_count - ); - - return { - suggestions: sortedSuggestions - .slice(0, 10) // only return top 10 results - .reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { - acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); - return acc; - }, []), - }; - }, - }, - - /** - * the "Subtype Nested" query / parser should be used when the options list is built on a field with subtype nested. - */ - subtypeNested: { - buildAggregation: (req: OptionsListRequestBody) => { - const { fieldSpec, fieldName, searchString, sort } = req; - const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); - if (!subTypeNested) { - // if this field is not subtype nested, fall back to keywordOnly - return cheapSuggestionAggSubtypes.keywordOnly.buildAggregation(req); - } - return { - nestedSuggestions: { - nested: { - path: subTypeNested.nested.path, - }, - aggs: { - suggestions: { - terms: { - field: fieldName, - ...(searchString && searchString.length > 0 - ? { include: `${getEscapedRegexQuery(searchString)}.*` } - : {}), - shard_size: 10, - order: getSortType(sort), - }, - }, - }, - }, - }; - }, - parse: (rawEsResult) => ({ - suggestions: get(rawEsResult, 'aggregations.nestedSuggestions.suggestions.buckets')?.reduce( - (acc: OptionsListSuggestions, suggestion: EsBucket) => { - acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); - return acc; - }, - [] - ), - }), - }, -}; diff --git a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts index b2a42fa0b3b19..310a04874f7fb 100644 --- a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts +++ b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts @@ -8,17 +8,15 @@ import { Observable } from 'rxjs'; -import { PluginSetup as UnifiedSearchPluginSetup } from '@kbn/unified-search-plugin/server'; -import { getKbnServerError, reportServerError } from '@kbn/kibana-utils-plugin/server'; +import { schema } from '@kbn/config-schema'; import { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; import { SearchRequest } from '@kbn/data-plugin/common'; -import { schema } from '@kbn/config-schema'; +import { getKbnServerError, reportServerError } from '@kbn/kibana-utils-plugin/server'; +import { PluginSetup as UnifiedSearchPluginSetup } from '@kbn/unified-search-plugin/server'; import { OptionsListRequestBody, OptionsListResponse } from '../../common/options_list/types'; import { getValidationAggregationBuilder } from './options_list_validation_queries'; -import { getExpensiveSuggestionAggregationBuilder } from './options_list_expensive_suggestion_queries'; -import { getCheapSuggestionAggregationBuilder } from './options_list_cheap_suggestion_queries'; -import { OptionsListSuggestionAggregationBuilder } from './types'; +import { getSuggestionAggregationBuilder } from './suggestion_queries'; export const setupOptionsListSuggestionsRoute = ( { http }: CoreSetup, @@ -51,6 +49,13 @@ export const setupOptionsListSuggestionsRoute = ( fieldSpec: schema.maybe(schema.any()), allowExpensiveQueries: schema.boolean(), searchString: schema.maybe(schema.string()), + searchTechnique: schema.maybe( + schema.oneOf([ + schema.literal('exact'), + schema.literal('prefix'), + schema.literal('wildcard'), + ]) + ), selectedOptions: schema.maybe( schema.oneOf([schema.arrayOf(schema.string()), schema.arrayOf(schema.number())]) ), @@ -97,18 +102,13 @@ export const setupOptionsListSuggestionsRoute = ( /** * Build ES Query */ - const { runPastTimeout, filters, runtimeFieldMap, allowExpensiveQueries } = request; + const { runPastTimeout, filters, runtimeFieldMap } = request; const { terminateAfter, timeout } = getAutocompleteSettings(); const timeoutSettings = runPastTimeout ? {} : { timeout: `${timeout}ms`, terminate_after: terminateAfter }; - let suggestionBuilder: OptionsListSuggestionAggregationBuilder; - if (allowExpensiveQueries) { - suggestionBuilder = getExpensiveSuggestionAggregationBuilder(request); - } else { - suggestionBuilder = getCheapSuggestionAggregationBuilder(request); - } + const suggestionBuilder = getSuggestionAggregationBuilder(request); const validationBuilder = getValidationAggregationBuilder(); const suggestionAggregation: any = suggestionBuilder.buildAggregation(request) ?? {}; @@ -134,6 +134,7 @@ export const setupOptionsListSuggestionsRoute = ( ...runtimeFieldMap, }, }; + /** * Run ES query */ diff --git a/src/plugins/controls/server/options_list/suggestion_queries/index.ts b/src/plugins/controls/server/options_list/suggestion_queries/index.ts new file mode 100644 index 0000000000000..5474873ed5385 --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getSuggestionAggregationBuilder } from './options_list_suggestion_queries'; diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.test.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.test.ts new file mode 100644 index 0000000000000..6b785c8c0bdb3 --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.test.ts @@ -0,0 +1,142 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { OptionsListRequestBody } from '../../../common/options_list/types'; +import { getAllSuggestionsAggregationBuilder } from './options_list_all_suggestions'; + +describe('options list fetch all suggestions query', () => { + describe('suggestion aggregation', () => { + test('number field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + fieldSpec: { + type: 'number', + } as unknown as FieldSpec, + sort: { + by: '_key', + direction: 'asc', + }, + }; + const aggregationBuilder = getAllSuggestionsAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + + expect(aggregation).toMatchObject({ + suggestions: { + terms: { + size: 10, + shard_size: 10, + field: 'bytes', + order: { + _key: 'asc', + }, + }, + }, + unique_terms: { + cardinality: { + field: 'bytes', + }, + }, + }); + }); + + test('nested string (keyword, text+keyword) field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'testField', + allowExpensiveQueries: true, + fieldSpec: { + type: 'string', + subType: { nested: { path: 'path.to.nested' } }, + } as unknown as FieldSpec, + }; + const aggregationBuilder = getAllSuggestionsAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + + expect(aggregation).toMatchObject({ + nestedSuggestions: { + nested: { + path: 'path.to.nested', + }, + aggs: { + suggestions: { + terms: { + size: 10, + shard_size: 10, + field: 'testField', + order: { + _count: 'desc', + }, + }, + }, + unique_terms: { + cardinality: { + field: 'testField', + }, + }, + }, + }, + }); + }); + }); + + test('suggestion parsing', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + fieldSpec: { + type: 'number', + } as unknown as FieldSpec, + sort: { + by: '_key', + direction: 'asc', + }, + }; + const aggregationBuilder = getAllSuggestionsAggregationBuilder(); + const searchResponseMock = { + hits: { + total: 10, + max_score: 10, + hits: [], + }, + took: 10, + timed_out: false, + _shards: { + failed: 0, + successful: 1, + total: 1, + skipped: 0, + }, + aggregations: { + suggestions: { + buckets: [ + { doc_count: 5, key: '1' }, + { doc_count: 4, key: '2' }, + { doc_count: 3, key: '3' }, + ], + }, + unique_terms: { + value: 3, + }, + }, + }; + + const parsed = aggregationBuilder.parse(searchResponseMock, optionsListRequestBodyMock); + expect(parsed).toMatchObject({ + suggestions: [ + { value: '1', docCount: 5 }, + { value: '2', docCount: 4 }, + { value: '3', docCount: 3 }, + ], + totalCardinality: 3, + }); + }); +}); diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.ts new file mode 100644 index 0000000000000..e3f82bd4a9baf --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.ts @@ -0,0 +1,90 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; +import { get } from 'lodash'; + +import { OptionsListRequestBody, OptionsListSuggestions } from '../../../common/options_list/types'; +import { EsBucket, OptionsListSuggestionAggregationBuilder } from '../types'; +import { getSortType } from './options_list_suggestion_query_helpers'; + +/** + * Fetch all suggestions without any additional searching/filtering. + * This query will be more-or-less the same for **all** field types, + */ +export const getAllSuggestionsAggregationBuilder: () => OptionsListSuggestionAggregationBuilder = + () => allSuggestionsAggregationBuilder; + +const allSuggestionsAggregationBuilder: OptionsListSuggestionAggregationBuilder = { + buildAggregation: ({ + fieldName, + fieldSpec, + sort, + size, + allowExpensiveQueries, + }: OptionsListRequestBody) => { + let suggestionsAgg: { suggestions: any; unique_terms?: any } = { + suggestions: { + terms: { + size, + field: fieldName, + shard_size: 10, + order: getSortType(sort), + }, + }, + }; + + if (allowExpensiveQueries) { + suggestionsAgg = { + ...suggestionsAgg, + unique_terms: { + cardinality: { + field: fieldName, + }, + }, + }; + } + + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + if (subTypeNested) { + return { + nestedSuggestions: { + nested: { + path: subTypeNested.nested.path, + }, + aggs: { + ...suggestionsAgg, + }, + }, + }; + } + + return suggestionsAgg; + }, + parse: (rawEsResult, { fieldSpec, allowExpensiveQueries }: OptionsListRequestBody) => { + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + const suggestions = get( + rawEsResult, + `aggregations.${subTypeNested ? 'nestedSuggestions.suggestions' : 'suggestions'}.buckets` + )?.reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { + acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); + return acc; + }, []); + return { + suggestions, + totalCardinality: allowExpensiveQueries + ? get( + rawEsResult, + `aggregations.${ + subTypeNested ? 'nestedSuggestions.unique_terms' : 'unique_terms' + }.value` + ) + : undefined, + }; + }, +}; diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.test.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.test.ts new file mode 100644 index 0000000000000..77ec137e0dbe7 --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.test.ts @@ -0,0 +1,180 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { OptionsListRequestBody } from '../../../common/options_list/types'; +import { getExactMatchAggregationBuilder } from './options_list_exact_match'; + +describe('options list exact match search query', () => { + test('returns empty result when given invalid search', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + sort: { by: '_key', direction: 'desc' }, + searchString: '1a2b3c', + fieldSpec: { type: 'number' } as unknown as FieldSpec, + }; + const aggregationBuilder = getExactMatchAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + expect(aggregation).toEqual({}); + const parsed = aggregationBuilder.parse( + {} as any as SearchResponse, + optionsListRequestBodyMock + ); + expect(parsed).toEqual({ suggestions: [], totalCardinality: 0 }); + }); + + describe('suggestion aggregation', () => { + test('string (keyword, text+keyword) field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'testField', + allowExpensiveQueries: true, + searchString: 'searchForMe', + fieldSpec: { type: 'string' } as unknown as FieldSpec, + }; + const aggregationBuilder = getExactMatchAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + expect(aggregation).toMatchObject({ + suggestions: { + filter: { + term: { + testField: { + value: 'searchForMe', + case_insensitive: true, + }, + }, + }, + aggs: { + filteredSuggestions: { + terms: { + field: 'testField', + shard_size: 10, + }, + }, + }, + }, + }); + }); + + test('nested string (keyword, text+keyword) field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'testField', + allowExpensiveQueries: true, + searchString: 'searchForMe', + fieldSpec: { + type: 'string', + subType: { nested: { path: 'path.to.nested' } }, + } as unknown as FieldSpec, + }; + const aggregationBuilder = getExactMatchAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + + expect(aggregation).toMatchObject({ + nestedSuggestions: { + nested: { + path: 'path.to.nested', + }, + aggs: { + suggestions: { + filter: { + term: { + testField: { + value: 'searchForMe', + case_insensitive: true, + }, + }, + }, + aggs: { + filteredSuggestions: { + terms: { + field: 'testField', + shard_size: 10, + }, + }, + }, + }, + }, + }, + }); + }); + + test('number field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + searchString: '123', + fieldSpec: { type: 'number' } as unknown as FieldSpec, + }; + const aggregationBuilder = getExactMatchAggregationBuilder(); + const aggregation = aggregationBuilder.buildAggregation(optionsListRequestBodyMock); + expect(aggregation).toMatchObject({ + suggestions: { + filter: { + term: { + bytes: { + value: '123', + case_insensitive: false, // this is the only part that is dependent on field type + }, + }, + }, + aggs: { + filteredSuggestions: { + terms: { + field: 'bytes', + shard_size: 10, + }, + }, + }, + }, + }); + }); + }); + + test('suggestion parsing', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + searchString: 'cool', + allowExpensiveQueries: true, + fieldName: 'coolTestField.keyword', + fieldSpec: { type: 'string' } as unknown as FieldSpec, + }; + const aggregationBuilder = getExactMatchAggregationBuilder(); + + const searchResponseMock = { + hits: { + total: 1, + max_score: 1, + hits: [], + }, + took: 10, + timed_out: false, + _shards: { + failed: 0, + successful: 1, + total: 1, + skipped: 0, + }, + aggregations: { + suggestions: { + filteredSuggestions: { + buckets: [{ doc_count: 5, key: 'cool1' }], + }, + }, + }, + }; + expect(aggregationBuilder.parse(searchResponseMock, optionsListRequestBodyMock)).toMatchObject({ + suggestions: [{ docCount: 5, value: 'cool1' }], + totalCardinality: 1, + }); + }); +}); diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.ts new file mode 100644 index 0000000000000..e9da0c029418f --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.ts @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; +import { get } from 'lodash'; + +import { isValidSearch } from '../../../common/options_list/is_valid_search'; +import { OptionsListRequestBody, OptionsListSuggestions } from '../../../common/options_list/types'; +import { EsBucket, OptionsListSuggestionAggregationBuilder } from '../types'; + +/** + * Search for an exact match based on the provided search string. + * This query will be more-or-less the same for **all** field types, and it should only ever return + * 0 (if no match) or 1 (if a match was found) results. + */ +export const getExactMatchAggregationBuilder: () => OptionsListSuggestionAggregationBuilder = () => + exactMatchAggregationBuilder; + +const exactMatchAggregationBuilder: OptionsListSuggestionAggregationBuilder = { + buildAggregation: ({ fieldName, fieldSpec, searchString }: OptionsListRequestBody) => { + if (!isValidSearch({ searchString, fieldType: fieldSpec?.type, searchTechnique: 'exact' })) { + return {}; + } + + const suggestionsAgg = { + suggestions: { + filter: { + term: { + [fieldName]: { + value: searchString, + case_insensitive: fieldSpec?.type === 'string', + }, + }, + }, + aggs: { + filteredSuggestions: { + terms: { + field: fieldName, + shard_size: 10, + }, + }, + }, + }, + }; + + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + if (subTypeNested) { + return { + nestedSuggestions: { + nested: { + path: subTypeNested.nested.path, + }, + aggs: { + ...suggestionsAgg, + }, + }, + }; + } + + return suggestionsAgg; + }, + parse: (rawEsResult, { searchString, fieldSpec }) => { + if (!isValidSearch({ searchString, fieldType: fieldSpec?.type, searchTechnique: 'exact' })) { + // if this is happens, that means there is an invalid search that snuck through to the server side code; + // so, might as well early return with no suggestions + return { suggestions: [], totalCardinality: 0 }; + } + + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + + const suggestions = get( + rawEsResult, + `aggregations.${ + subTypeNested ? 'nestedSuggestions.suggestions' : 'suggestions' + }.filteredSuggestions.buckets` + )?.reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { + acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); + return acc; + }, []); + + return { + suggestions, + totalCardinality: suggestions.length, // should only be 0 or 1, so it's safe to use length here + }; + }, +}; diff --git a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.test.ts similarity index 66% rename from src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts rename to src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.test.ts index a5d6f32f63377..b2ddb699ebaad 100644 --- a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.test.ts @@ -6,13 +6,18 @@ * Side Public License, v 1. */ -import { FieldSpec } from '@kbn/data-views-plugin/common'; import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { FieldSpec } from '@kbn/data-views-plugin/common'; + +import { OptionsListRequestBody } from '../../../common/options_list/types'; +import { getExactMatchAggregationBuilder } from './options_list_exact_match'; +import { getSearchSuggestionsAggregationBuilder } from './options_list_search_suggestions'; -import { getExpensiveSuggestionAggregationBuilder } from './options_list_expensive_suggestion_queries'; -import { OptionsListRequestBody } from '../../common/options_list/types'; +jest.mock('./options_list_exact_match', () => ({ + getExactMatchAggregationBuilder: jest.fn(), +})); -describe('options list expensive queries', () => { +describe('options list type-specific search queries', () => { let rawSearchResponseMock: SearchResponse = {} as SearchResponse; beforeEach(() => { @@ -35,40 +40,19 @@ describe('options list expensive queries', () => { }); describe('suggestion aggregation', () => { - describe('string (keyword, text+keyword, or nested) field', () => { - test('test keyword field, without a search string', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - allowExpensiveQueries: true, - fieldName: 'coolTestField.keyword', - sort: { by: '_key', direction: 'asc' }, - fieldSpec: { aggregatable: true } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "coolTestField.keyword", - "order": Object { - "_key": "asc", - }, - "shard_size": 10, - "size": 10, - }, - }, - "unique_terms": Object { - "cardinality": Object { - "field": "coolTestField.keyword", - }, - }, - } - `); - }); + test('for unsupported field types, return exact match search instead', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'success', + allowExpensiveQueries: true, + sort: { by: '_key', direction: 'desc' }, + fieldSpec: { type: 'boolean' } as unknown as FieldSpec, + }; + getSearchSuggestionsAggregationBuilder(optionsListRequestBodyMock); + expect(getExactMatchAggregationBuilder).toBeCalled(); + }); + describe('string (keyword, text+keyword, or nested) field', () => { test('test keyword field, with a search string', () => { const optionsListRequestBodyMock: OptionsListRequestBody = { size: 10, @@ -76,9 +60,9 @@ describe('options list expensive queries', () => { allowExpensiveQueries: true, fieldName: 'coolTestField.keyword', sort: { by: '_key', direction: 'desc' }, - fieldSpec: { aggregatable: true } as unknown as FieldSpec, + fieldSpec: { type: 'string' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -123,9 +107,9 @@ describe('options list expensive queries', () => { allowExpensiveQueries: true, fieldName: 'coolTestField.keyword', sort: { by: '_key', direction: 'desc' }, - fieldSpec: { aggregatable: true } as unknown as FieldSpec, + fieldSpec: { type: 'string' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -170,9 +154,9 @@ describe('options list expensive queries', () => { allowExpensiveQueries: true, fieldName: 'coolTestField.keyword', sort: { by: '_key', direction: 'desc' }, - fieldSpec: { aggregatable: true } as unknown as FieldSpec, + fieldSpec: { type: 'string' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -216,9 +200,12 @@ describe('options list expensive queries', () => { allowExpensiveQueries: true, fieldName: 'coolNestedField', sort: { by: '_count', direction: 'asc' }, - fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, + fieldSpec: { + type: 'string', + subType: { nested: { path: 'path.to.nested' } }, + } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -263,151 +250,20 @@ describe('options list expensive queries', () => { }); }); - describe('boolean field', () => { - test('creates boolean aggregation for boolean field', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'coolean', - allowExpensiveQueries: true, - sort: { by: '_key', direction: 'desc' }, - fieldSpec: { type: 'boolean' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "coolean", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - }, - }, - } - `); - }); - }); - - describe('date field field', () => { - test('creates date aggregation for date field', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: '@timestamp', - allowExpensiveQueries: true, - sort: { by: '_key', direction: 'desc' }, - fieldSpec: { type: 'date' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "@timestamp", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - "size": 10, - }, - }, - "unique_terms": Object { - "cardinality": Object { - "field": "@timestamp", - }, - }, - } - `); - }); - - test('does not throw error when receiving search string', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: '@timestamp', - allowExpensiveQueries: true, - sort: { by: '_key', direction: 'desc' }, - searchString: '2023', - fieldSpec: { type: 'date' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "terms": Object { - "field": "@timestamp", - "order": Object { - "_key": "desc", - }, - "shard_size": 10, - "size": 10, - }, - }, - "unique_terms": Object { - "cardinality": Object { - "field": "@timestamp", - }, - }, - } - `); - }); - }); - describe('IP field', () => { - test('without a search string, creates IP range aggregation with default range', () => { + test('handles an invalid search', () => { const optionsListRequestBodyMock: OptionsListRequestBody = { size: 10, fieldName: 'clientip', allowExpensiveQueries: true, sort: { by: '_key', direction: 'asc' }, + searchString: '1.a.2.b.3.z', fieldSpec: { type: 'ip' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); - expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Object { - "aggs": Object { - "filteredSuggestions": Object { - "terms": Object { - "field": "clientip", - "order": Object { - "_key": "asc", - }, - "shard_size": 10, - "size": 10, - }, - }, - "unique_terms": Object { - "cardinality": Object { - "field": "clientip", - }, - }, - }, - "ip_range": Object { - "field": "clientip", - "keyed": true, - "ranges": Array [ - Object { - "from": "::", - "key": "ipv6", - "to": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - }, - ], - }, - }, - } - `); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)).toEqual({}); }); test('full IPv4 in the search string, creates IP range aggregation with CIDR mask', () => { @@ -419,7 +275,7 @@ describe('options list expensive queries', () => { sort: { by: '_count', direction: 'asc' }, fieldSpec: { type: 'ip' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -467,7 +323,7 @@ describe('options list expensive queries', () => { fieldSpec: { type: 'ip' } as unknown as FieldSpec, searchString: 'f688:fb50:6433:bba2:604:f2c:194a:d3c5', }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -514,7 +370,7 @@ describe('options list expensive queries', () => { allowExpensiveQueries: true, fieldSpec: { type: 'ip' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -563,7 +419,7 @@ describe('options list expensive queries', () => { sort: { by: '_count', direction: 'desc' }, fieldSpec: { type: 'ip' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) @@ -606,26 +462,29 @@ describe('options list expensive queries', () => { }); describe('suggestion parsing', () => { - test('parses string (keyword, text+keyword, or nested) result', () => { + test('parses string (keyword, text+keyword) result', () => { const optionsListRequestBodyMock: OptionsListRequestBody = { size: 10, + searchString: 'cool', allowExpensiveQueries: true, fieldName: 'coolTestField.keyword', - fieldSpec: { aggregatable: true } as unknown as FieldSpec, + fieldSpec: { type: 'string' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 5, key: 'cool1' }, - { doc_count: 15, key: 'cool2' }, - { doc_count: 10, key: 'cool3' }, - ], - }, - unique_terms: { - value: 3, + filteredSuggestions: { + suggestions: { + buckets: [ + { doc_count: 5, key: 'cool1' }, + { doc_count: 15, key: 'cool2' }, + { doc_count: 10, key: 'cool3' }, + ], + }, + unique_terms: { + value: 3, + }, }, }; expect(suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock)) @@ -650,51 +509,18 @@ describe('options list expensive queries', () => { `); }); - test('parses boolean result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: 'coolean', - allowExpensiveQueries: true, - fieldSpec: { type: 'boolean' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 55, key_as_string: 'false' }, - { doc_count: 155, key_as_string: 'true' }, - ], - }, - }; - expect(suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock)) - .toMatchInlineSnapshot(` - Object { - "suggestions": Array [ - Object { - "docCount": 55, - "value": "false", - }, - Object { - "docCount": 155, - "value": "true", - }, - ], - "totalCardinality": 2, - } - `); - }); - - test('parses nested result', () => { + test('parses string nested result', () => { const optionsListRequestBodyMock: OptionsListRequestBody = { size: 10, searchString: 'co', fieldName: 'coolNestedField', allowExpensiveQueries: true, - fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, + fieldSpec: { + type: 'string', + subType: { type: 'string', nested: { path: 'path.to.nested' } }, + } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); rawSearchResponseMock.aggregations = { @@ -738,11 +564,12 @@ describe('options list expensive queries', () => { test('parses mixed IPv4 and IPv6 result', () => { const optionsListRequestBodyMock: OptionsListRequestBody = { size: 10, + searchString: '21', fieldName: 'clientip', allowExpensiveQueries: true, fieldSpec: { type: 'ip' } as unknown as FieldSpec, }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( + const suggestionAggBuilder = getSearchSuggestionsAggregationBuilder( optionsListRequestBodyMock ); rawSearchResponseMock.aggregations = { @@ -840,53 +667,5 @@ describe('options list expensive queries', () => { ] `); }); - - test('parses date result', () => { - const optionsListRequestBodyMock: OptionsListRequestBody = { - size: 10, - fieldName: '@timestamp', - allowExpensiveQueries: true, - fieldSpec: { type: 'date' } as unknown as FieldSpec, - }; - const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder( - optionsListRequestBodyMock - ); - rawSearchResponseMock.aggregations = { - suggestions: { - buckets: [ - { doc_count: 20, key: 1696824675 }, - { doc_count: 13, key: 1686086625 }, - { doc_count: 4, key: 1703684229 }, - { doc_count: 34, key: 1688603684 }, - ], - }, - }; - - const parsed = suggestionAggBuilder.parse( - rawSearchResponseMock, - optionsListRequestBodyMock - ).suggestions; - - expect(parsed).toMatchInlineSnapshot(` - Array [ - Object { - "docCount": 20, - "value": 1696824675, - }, - Object { - "docCount": 13, - "value": 1686086625, - }, - Object { - "docCount": 4, - "value": 1703684229, - }, - Object { - "docCount": 34, - "value": 1688603684, - }, - ] - `); - }); }); }); diff --git a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.ts similarity index 57% rename from src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts rename to src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.ts index 156a966f6a482..47f1fe5f696dc 100644 --- a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_search_suggestions.ts @@ -6,16 +6,15 @@ * Side Public License, v 1. */ -import { get } from 'lodash'; import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; +import { get } from 'lodash'; -import { - OptionsListRequestBody, - OptionsListSuggestions, - OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE, -} from '../../common/options_list/types'; -import { getIpRangeQuery, type IpRangeQuery } from '../../common/options_list/ip_search'; -import { EsBucket, OptionsListSuggestionAggregationBuilder } from './types'; +import { getIpRangeQuery } from '../../../common/options_list/ip_search'; +import { isValidSearch } from '../../../common/options_list/is_valid_search'; +import { getDefaultSearchTechnique } from '../../../common/options_list/suggestions_searching'; +import { OptionsListRequestBody, OptionsListSuggestions } from '../../../common/options_list/types'; +import { EsBucket, OptionsListSuggestionAggregationBuilder } from '../types'; +import { getExactMatchAggregationBuilder } from './options_list_exact_match'; import { getEscapedWildcardQuery, getIpBuckets, @@ -23,19 +22,28 @@ import { } from './options_list_suggestion_query_helpers'; /** - * Suggestion aggregations + * Type-specific search suggestion aggregations. These queries are highly impacted by the field type. */ -export const getExpensiveSuggestionAggregationBuilder = ({ fieldSpec }: OptionsListRequestBody) => { - if (fieldSpec?.type === 'boolean') { - return expensiveSuggestionAggSubtypes.boolean; - } - if (fieldSpec?.type === 'ip') { - return expensiveSuggestionAggSubtypes.ip; +export const getSearchSuggestionsAggregationBuilder = (request: OptionsListRequestBody) => { + const { fieldSpec } = request; + + // note that date and boolean fields are non-searchable, so type-specific search aggs are not necessary; + // number fields, on the other hand, only support exact match searching - so, this also does not need a + // type-specific agg because it will be handled by `exactMatchSearchAggregation` + switch (fieldSpec?.type) { + case 'ip': { + return suggestionAggSubtypes.ip; + } + case 'string': { + return suggestionAggSubtypes.textOrKeywordOrNested; + } + default: + // safe guard just in case an invalid/unsupported field type somehow got through + return getExactMatchAggregationBuilder(); } - return expensiveSuggestionAggSubtypes.textOrKeywordOrNested; }; -const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregationBuilder } = { +const suggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregationBuilder } = { /** * The "textOrKeywordOrNested" query / parser should be used whenever the field is built on some type of string field, * regardless of if it is keyword only, keyword+text, or some nested keyword/keyword+text field. @@ -49,28 +57,19 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr sort, size, }: OptionsListRequestBody) => { + const hasSearchString = searchString && searchString.length > 0; + if (!hasSearchString || fieldSpec?.type === 'date') { + // we can assume that this is only ever called with a search string, and date fields are not + // currently searchable; so, if any of these things is true, this is invalid. + return undefined; + } + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); let textOrKeywordQuery: any = { - suggestions: { - terms: { - size, - field: fieldName, - shard_size: 10, - order: getSortType(sort), - }, - }, - unique_terms: { - cardinality: { - field: fieldName, - }, - }, - }; - // disabling for date fields because applying a search string will return an error - if (fieldSpec?.type !== 'date' && searchString && searchString.length > 0) { - textOrKeywordQuery = { - filteredSuggestions: { - filter: { - [(searchTechnique ?? OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE) as string]: { + filteredSuggestions: { + filter: { + [(searchTechnique ?? getDefaultSearchTechnique(fieldSpec?.type ?? 'string')) as string]: + { [fieldName]: { value: searchTechnique === 'wildcard' @@ -79,11 +78,25 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr case_insensitive: true, }, }, + }, + aggs: { + suggestions: { + terms: { + size, + field: fieldName, + shard_size: 10, + order: getSortType(sort), + }, + }, + unique_terms: { + cardinality: { + field: fieldName, + }, }, - aggs: { ...textOrKeywordQuery }, }, - }; - } + }, + }; + if (subTypeNested) { textOrKeywordQuery = { nestedSuggestions: { @@ -98,11 +111,10 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr } return textOrKeywordQuery; }, - parse: (rawEsResult, request) => { + parse: (rawEsResult, { fieldSpec }) => { let basePath = 'aggregations'; - const isNested = request.fieldSpec && getFieldSubtypeNested(request.fieldSpec); - basePath += isNested ? '.nestedSuggestions' : ''; - basePath += request.searchString ? '.filteredSuggestions' : ''; + const isNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + basePath += isNested ? '.nestedSuggestions.filteredSuggestions' : '.filteredSuggestions'; const suggestions = get(rawEsResult, `${basePath}.suggestions.buckets`)?.reduce( (acc: OptionsListSuggestions, suggestion: EsBucket) => { @@ -119,55 +131,23 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr }, /** - * the "Boolean" query / parser should be used when the options list is built on a field of type boolean. The query is slightly different than a keyword query. + * the "IP" query / parser should be used when the options list is built on a field of type IP. */ - boolean: { - buildAggregation: ({ fieldName, sort }: OptionsListRequestBody) => ({ - suggestions: { + ip: { + buildAggregation: ({ fieldName, searchString, sort, size }: OptionsListRequestBody) => { + const filteredSuggestions = { terms: { + size, field: fieldName, shard_size: 10, order: getSortType(sort), }, - }, - }), - parse: (rawEsResult) => { - const suggestions = get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( - (acc: OptionsListSuggestions, suggestion: EsBucket & { key_as_string: string }) => { - acc.push({ value: suggestion.key_as_string, docCount: suggestion.doc_count }); - return acc; - }, - [] - ); - return { suggestions, totalCardinality: suggestions.length }; // cardinality is only ever 0, 1, or 2 so safe to use length here - }, - }, - - /** - * the "IP" query / parser should be used when the options list is built on a field of type IP. - */ - ip: { - buildAggregation: ({ fieldName, searchString, sort, size }: OptionsListRequestBody) => { - let ipRangeQuery: IpRangeQuery = { - validSearch: true, - rangeQuery: [ - { - key: 'ipv6', - from: '::', - to: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', - }, - ], }; - if (searchString && searchString.length > 0) { - ipRangeQuery = getIpRangeQuery(searchString); - if (!ipRangeQuery.validSearch) { - // ideally should be prevented on the client side but, if somehow an invalid search gets through to the server, - // simply don't return an aggregation query for the ES search request - return undefined; - } + const ipRangeQuery = getIpRangeQuery(searchString ?? ''); + if (!ipRangeQuery.validSearch) { + return {}; } - return { suggestions: { ip_range: { @@ -176,14 +156,7 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr keyed: true, }, aggs: { - filteredSuggestions: { - terms: { - size, - field: fieldName, - shard_size: 10, - order: getSortType(sort), - }, - }, + filteredSuggestions, unique_terms: { cardinality: { field: fieldName, @@ -193,18 +166,22 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr }, }; }, - parse: (rawEsResult, request) => { - if (!Boolean(rawEsResult.aggregations?.suggestions)) { + parse: (rawEsResult, { searchString, sort, fieldSpec, size, searchTechnique }) => { + if ( + !searchString || + !isValidSearch({ searchString, fieldType: fieldSpec?.type, searchTechnique }) + ) { // if this is happens, that means there is an invalid search that snuck through to the server side code; // so, might as well early return with no suggestions return { suggestions: [], totalCardinality: 0 }; } + const buckets: EsBucket[] = []; getIpBuckets(rawEsResult, buckets, 'ipv4'); // modifies buckets array directly, i.e. "by reference" getIpBuckets(rawEsResult, buckets, 'ipv6'); const sortedSuggestions = - request.sort?.direction === 'asc' + sort?.direction === 'asc' ? buckets.sort( (bucketA: EsBucket, bucketB: EsBucket) => bucketA.doc_count - bucketB.doc_count ) @@ -213,7 +190,7 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr ); const suggestions = sortedSuggestions - .slice(0, request.size) + .slice(0, size) .reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { acc.push({ value: suggestion.key, docCount: suggestion.doc_count }); return acc; diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.test.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.test.ts new file mode 100644 index 0000000000000..46f0af574f775 --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.test.ts @@ -0,0 +1,94 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { OptionsListRequestBody } from '../../../common/options_list/types'; +import { getAllSuggestionsAggregationBuilder } from './options_list_all_suggestions'; +import { getExactMatchAggregationBuilder } from './options_list_exact_match'; +import { getSearchSuggestionsAggregationBuilder } from './options_list_search_suggestions'; +import { getSuggestionAggregationBuilder } from './options_list_suggestion_queries'; + +jest.mock('./options_list_all_suggestions', () => ({ + getAllSuggestionsAggregationBuilder: jest.fn(), +})); + +jest.mock('./options_list_exact_match', () => ({ + getExactMatchAggregationBuilder: jest.fn(), +})); + +jest.mock('./options_list_search_suggestions', () => ({ + getSearchSuggestionsAggregationBuilder: jest.fn(), +})); + +describe('options list suggestion queries', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('returns generic fetch all aggregation when no search string is provided', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: '@timestamp', + allowExpensiveQueries: true, + sort: { by: '_key', direction: 'desc' }, + fieldSpec: { type: 'date' } as unknown as FieldSpec, + }; + getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(getAllSuggestionsAggregationBuilder).toBeCalled(); + expect(getExactMatchAggregationBuilder).not.toBeCalled(); + expect(getSearchSuggestionsAggregationBuilder).not.toBeCalled(); + }); + + test('returns generic exact match search query when search technique is `exact`', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + searchTechnique: 'exact', + searchString: 'searchForMe', + sort: { by: '_key', direction: 'asc' }, + fieldSpec: { type: 'number' } as unknown as FieldSpec, + }; + getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(getAllSuggestionsAggregationBuilder).not.toBeCalled(); + expect(getExactMatchAggregationBuilder).toBeCalled(); + expect(getSearchSuggestionsAggregationBuilder).not.toBeCalled(); + }); + + test('returns generic exact match search query when allowExpensiveQueries is `false`', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: false, + searchTechnique: 'prefix', + searchString: 'searchForMe', + sort: { by: '_key', direction: 'asc' }, + fieldSpec: { type: 'number' } as unknown as FieldSpec, + }; + getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(getAllSuggestionsAggregationBuilder).not.toBeCalled(); + expect(getExactMatchAggregationBuilder).toBeCalled(); + expect(getSearchSuggestionsAggregationBuilder).not.toBeCalled(); + }); + + test('returns type-specific search query only when absolutely necessary', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + size: 10, + fieldName: 'bytes', + allowExpensiveQueries: true, + searchTechnique: 'prefix', + searchString: 'searchForMe', + sort: { by: '_key', direction: 'asc' }, + fieldSpec: { type: 'keyword' } as unknown as FieldSpec, + }; + getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(getAllSuggestionsAggregationBuilder).not.toBeCalled(); + expect(getExactMatchAggregationBuilder).not.toBeCalled(); + expect(getSearchSuggestionsAggregationBuilder).toBeCalled(); + }); +}); diff --git a/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.ts new file mode 100644 index 0000000000000..56e7820c8260e --- /dev/null +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.ts @@ -0,0 +1,32 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OptionsListRequestBody } from '../../../common/options_list/types'; +import { getAllSuggestionsAggregationBuilder } from './options_list_all_suggestions'; +import { getExactMatchAggregationBuilder } from './options_list_exact_match'; +import { getSearchSuggestionsAggregationBuilder } from './options_list_search_suggestions'; + +/** + * Suggestion aggregations + */ +export const getSuggestionAggregationBuilder = (request: OptionsListRequestBody) => { + const { searchString, searchTechnique, allowExpensiveQueries } = request; + const hasSearchString = searchString && searchString.length > 0; + if (!hasSearchString) { + // the field type only matters when there is a search string; so, if no search string, + // return generic "fetch all" aggregation builder + return getAllSuggestionsAggregationBuilder(); + } else if (!allowExpensiveQueries || searchTechnique === 'exact') { + // if `allowExpensiveQueries` is false, only support exact match searching; also, field type + // once again does not matter when building an exact match aggregation + return getExactMatchAggregationBuilder(); + } else { + // at this point, the type of the field matters - so, fetch the type-specific search agg + return getSearchSuggestionsAggregationBuilder(request); + } +}; diff --git a/src/plugins/controls/server/options_list/options_list_suggestion_query_helpers.ts b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_query_helpers.ts similarity index 92% rename from src/plugins/controls/server/options_list/options_list_suggestion_query_helpers.ts rename to src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_query_helpers.ts index 437450cc8ecf1..5986420f21f91 100644 --- a/src/plugins/controls/server/options_list/options_list_suggestion_query_helpers.ts +++ b/src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_query_helpers.ts @@ -8,11 +8,11 @@ import { get } from 'lodash'; -import { EsBucket } from './types'; import { - OPTIONS_LIST_DEFAULT_SORT, OptionsListSortingType, -} from '../../common/options_list/suggestions_sorting'; + OPTIONS_LIST_DEFAULT_SORT, +} from '../../../common/options_list/suggestions_sorting'; +import { EsBucket } from '../types'; export const getSortType = (sort?: OptionsListSortingType) => { return sort diff --git a/test/functional/apps/dashboard_elements/controls/common/range_slider.ts b/test/functional/apps/dashboard_elements/controls/common/range_slider.ts index 17a1873ed098f..e088066533250 100644 --- a/test/functional/apps/dashboard_elements/controls/common/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/common/range_slider.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; +import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -112,6 +112,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const secondId = (await dashboardControls.getAllControlIds())[1]; const newTitle = 'Average ticket price'; await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlsEditorVerifySupportedControlTypes({ + supportedTypes: [OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL], + selectedType: RANGE_SLIDER_CONTROL, + }); await dashboardControls.controlEditorSetTitle(newTitle); await dashboardControls.controlEditorSetWidth('large'); await dashboardControls.controlEditorSave(); @@ -128,7 +132,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL); + await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlsEditorSetControlType(RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(firstId); await dashboardControls.validateRange('placeholder', firstId, '0', '6'); diff --git a/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts index d3019a34b8802..974f5e942d42a 100644 --- a/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts @@ -26,9 +26,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const DASHBOARD_NAME = 'Test Replace Controls'; - const changeFieldType = async (controlId: string, newField: string, expectedType?: string) => { + const changeFieldType = async (controlId: string, newField: string, type: string) => { await dashboardControls.editExistingControl(controlId); - await dashboardControls.controlsEditorSetfield(newField, expectedType); + await dashboardControls.controlsEditorSetfield(newField); + await dashboardControls.controlsEditorSetControlType(type); await dashboardControls.controlEditorSave(); }; diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_allow_expensive_queries_off.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_allow_expensive_queries_off.ts index 91774aee02f2d..344143f7a0cc6 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list/options_list_allow_expensive_queries_off.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_allow_expensive_queries_off.ts @@ -69,9 +69,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('Can search options list for available options', async () => { + it('Can search options list for available options - exact match, case insensitive', async () => { await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('meo'); + await dashboardControls.optionsListPopoverSearchForOption('mEOw'); await dashboardControls.ensureAvailableOptionsEqual( controlId, { @@ -84,9 +84,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); }); - it('Can search options list for available options - case sensitive', async () => { + it('Can search options list for available options - does not find partial match', async () => { await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('MEO'); + await dashboardControls.optionsListPopoverSearchForOption('meo'); const cardinality = await dashboardControls.optionsListPopoverGetAvailableOptionsCount(); expect(cardinality).to.be(0); await dashboardControls.optionsListPopoverClearSearch(); diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts index 06dcfb6961f82..a19d5bfee82bb 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; +import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -106,7 +106,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('animals-*'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); + await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetControlType(OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); const selectionString = await dashboardControls.optionsListGetSelectionsString(firstId); @@ -141,6 +142,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.clearUnsavedChanges(); }); + it('can change an existing control to a number field', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + await dashboardControls.controlsEditorSetfield('weightLbs'); + await dashboardControls.controlsEditorVerifySupportedControlTypes({ + supportedTypes: [OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL], + selectedType: OPTIONS_LIST_CONTROL, + }); + await dashboardControls.controlEditorSave(); + }); + it('deletes an existing control', async () => { const firstId = (await dashboardControls.getAllControlIds())[0]; diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts index b6c27ece900ab..0b2dda536d41c 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts @@ -7,9 +7,10 @@ */ import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; +import expect from '@kbn/expect'; -import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -143,42 +144,57 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListPopoverClearSearch(); await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); }); - }); - it('wildcard searching causes unsaved changes', async () => { - await dashboardControls.editExistingControl(controlId); - await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'wildcard' }); - await dashboardControls.controlEditorSave(); - await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); - }); + it('wildcard searching causes unsaved changes', async () => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'wildcard' }); + await dashboardControls.controlEditorSave(); + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); - it('wildcard searching works as expected', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('r'); - const containsR = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS).reduce( - (result, [key, docCount]) => { - if (key.includes('r')) return { ...result, [key]: docCount }; - return { ...result }; - }, - {} - ); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { - suggestions: containsR, - invalidSelections: [], - }, - true - ); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); + it('wildcard searching works as expected', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('r'); + const containsR = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS).reduce( + (result, [key, docCount]) => { + if (key.includes('r')) return { ...result, [key]: docCount }; + return { ...result }; + }, + {} + ); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { + suggestions: containsR, + invalidSelections: [], + }, + true + ); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('exact match searching works as expected', async () => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'exact' }); + await dashboardControls.controlEditorSave(); - it('returning to default search technqiue should remove unsaved changes', async () => { - await dashboardControls.editExistingControl(controlId); - await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'prefix' }); - await dashboardControls.controlEditorSave(); - await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('R'); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + await dashboardControls.optionsListPopoverSearchForOption('RuFf'); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); + + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('returning to default search technique should remove unsaved changes', async () => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'prefix' }); + await dashboardControls.controlEditorSave(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); }); }); } diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index effac6f9fbdf9..4e1e9c1ccfdc1 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -6,24 +6,19 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; import { + ControlWidth, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, - ControlWidth, } from '@kbn/controls-plugin/common'; -import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/types'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; +import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching'; import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting'; +import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; -import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; - -const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { - default: 'No field selected yet', - [OPTIONS_LIST_CONTROL]: 'Options list', - [RANGE_SLIDER_CONTROL]: 'Range slider', -}; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; interface OptionsListAdditionalSettings { searchTechnique?: OptionsListSearchTechnique; @@ -112,7 +107,14 @@ export class DashboardPageControls extends FtrService { await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.controlEditorVerifyType('default'); + + /** All control type options should be disabled until a field is selected */ + const controlTypeOptions = await this.find.allByCssSelector( + '[data-test-subj="controlTypeMenu"] > li > button' + ); + await asyncForEach(controlTypeOptions, async (controlTypeOption) => { + expect(await controlTypeOption.isEnabled()).to.be(false); + }); } /* ----------------------------------------------------------- @@ -270,7 +272,10 @@ export class DashboardPageControls extends FtrService { await this.openCreateControlFlyout(); if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName, controlType); + if (fieldName) { + await this.controlsEditorSetfield(fieldName); + await this.controlsEditorSetControlType(controlType); + } if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); if (grow !== undefined) await this.controlEditorSetGrow(grow); @@ -601,11 +606,7 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async controlsEditorSetfield( - fieldName: string, - expectedType?: string, - shouldSearch: boolean = true - ) { + public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = true) { this.log.debug(`Setting control field to ${fieldName}`); if (shouldSearch) { await this.testSubjects.setValue('field-search-input', fieldName); @@ -614,13 +615,31 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); }); await this.testSubjects.click(`field-picker-select-${fieldName}`); - if (expectedType) await this.controlEditorVerifyType(expectedType); } - public async controlEditorVerifyType(type: string) { - this.log.debug(`Verifying that the control editor picked the type ${type}`); - const autoSelectedType = await this.testSubjects.getVisibleText('control-editor-type'); - expect(autoSelectedType).to.equal(CONTROL_DISPLAY_NAMES[type]); + public async controlsEditorVerifySupportedControlTypes({ + supportedTypes, + selectedType = OPTIONS_LIST_CONTROL, + }: { + supportedTypes: string[]; + selectedType?: string; + }) { + this.log.debug(`Verifying that control types match what is expected for the selected field`); + asyncForEach(supportedTypes, async (type) => { + const controlTypeItem = await this.testSubjects.find(`create__${type}`); + expect(await controlTypeItem.isEnabled()).to.be(true); + if (type === selectedType) { + expect(await controlTypeItem.getAttribute('aria-pressed')).to.be('true'); + } + }); + } + + public async controlsEditorSetControlType(type: string) { + this.log.debug(`Setting control type to ${type}`); + const controlTypeItem = await this.testSubjects.find(`create__${type}`); + expect(await controlTypeItem.isEnabled()).to.be(true); + await controlTypeItem.click(); + expect(await controlTypeItem.getAttribute('aria-pressed')).to.be('true'); } // Options List editor functions diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 1c0f90d9ad92e..83014ab40d7b4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -452,7 +452,6 @@ "controls.controlGroup.manageControl.dataSource.fieldTitle": "Champ", "controls.controlGroup.manageControl.dataSource.formGroupDescription": "Sélectionnez la vue de données et le champ pour lesquels vous voulez créer un contrôle.", "controls.controlGroup.manageControl.dataSource.formGroupTitle": "Source de données", - "controls.controlGroup.manageControl.dataSource.noControlTypeMessage": "Aucun champ sélectionné pour l’instant", "controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "Veuillez sélectionner une vue de données", "controls.controlGroup.manageControl.displaySettings.formGroupDescription": "Changez la manière dont le contrôle apparaît sur votre tableau de bord.", "controls.controlGroup.manageControl.displaySettings.formGroupTitle": "Paramètres d'affichage", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cc52a3743aca8..9dba513261801 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -452,7 +452,6 @@ "controls.controlGroup.manageControl.dataSource.fieldTitle": "フィールド", "controls.controlGroup.manageControl.dataSource.formGroupDescription": "コントロールを作成するデータビューとフィールドを選択します。", "controls.controlGroup.manageControl.dataSource.formGroupTitle": "データソース", - "controls.controlGroup.manageControl.dataSource.noControlTypeMessage": "まだフィールドが選択されていません", "controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "データビューを選択してください", "controls.controlGroup.manageControl.displaySettings.formGroupDescription": "ダッシュボードにコントロールを表示する方法を変更します。", "controls.controlGroup.manageControl.displaySettings.formGroupTitle": "表示設定", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4a724bbb7c4a6..8401f714b8350 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -452,7 +452,6 @@ "controls.controlGroup.manageControl.dataSource.fieldTitle": "字段", "controls.controlGroup.manageControl.dataSource.formGroupDescription": "选择要为其创建控件的数据视图和字段。", "controls.controlGroup.manageControl.dataSource.formGroupTitle": "数据源", - "controls.controlGroup.manageControl.dataSource.noControlTypeMessage": "尚未选择字段", "controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "请选择数据视图", "controls.controlGroup.manageControl.displaySettings.formGroupDescription": "更改控件在仪表板上的显示方式。", "controls.controlGroup.manageControl.displaySettings.formGroupTitle": "显示设置",