From 7d024a7b8640488454dad0fe236efcf9f3f941c3 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 18 Dec 2023 13:52:45 -0700 Subject: [PATCH] [Controls] Add numeric options list (#172106) Closes https://github.com/elastic/kibana/issues/143587 Closes https://github.com/elastic/kibana/issues/126795 ## Summary This PR adds support for numeric options lists - i.e. options list controls that are created with a numeric field. In order to make this possible, I had to add support for fields to have multiple, overlapping compatible control types (however, it is currently only number fields where this applies). When selecting a field that is compatible with multiple control types, the user is given the option to select which control type they want, like so:

GIF of multiple control types being
available for a single field

> [!NOTE] > This system currently defaults to options list controls, since these are the most common - this is backed up by our telemetry, which shows that (in the last 30 days), clusters with at least one options list control occured **ten times more often** than clusters with at least one range slider control. However, if we decide to introduce more control types (such as, for example, a date picker control), this assumption may no longer be the case - at that point, we would need to reevaluate whether the default should **always** be options list. ### Video https://github.com/elastic/kibana/assets/8698078/4a876c94-a041-4228-aab8-7c2c1c871071 ### Exact Match Searching Since numeric fields do not have a "prefix" query equivalent, the only possibility for searching these fields is either (1) a range query or (2) an exact match / equality query. In this PR, I added support for equality search - i.e. in order to find a value in a numeric options list, you must enter the *full*, exact term in order for it to be found; in the future, we could extend this to include a range search. This is the exact match searching in action:

GIF of exact match searching example on
number field

Since exact match searching is a generic search query that works for **all** field types, I added this as an option for **all** field types that support searching - i.e. keyword fields, number fields, IP fields, etc. For example, here is the supported search techniques for a keyword field:

GIF of all available search techniques for
keyword field

> [!TIP] > Exact match / equality / term searching on float values can lead to slightly unexpected results - refer to the [documentation on precision loss](https://www.elastic.co/guide/en/elasticsearch/reference/current/number.html#_which_type_should_i_use) for a description of why. Be mindful when using an options list control for float values, and consider whether a range slider might be a better solution! Eventually, we should be able to add exact-match searching to date fields, as well; however, due to [discrepancies that exist in the unified search bar](https://github.com/elastic/kibana/issues/172097) with respect to how searching date fields is handled, we should ideally wait until this is resolved so that we can be consistent across all three search experiences (query bar, filter pills, and controls). Depending on how the author expects a given control to be used, this search technique will return results **faster** than some of the other search types since "search as you type" will, more often than not, return zero results in this setting - that is why I chose to add it for keyword fields, as well. ### Searching when `allowExpensiveQueries` setting is `false` Previously, we had **two separate versions** of our options list search queries - one for when `allowExpensiveQueries` was `true`, and the other for when it was `false`. This was a significant amount of tech debt that was time consuming to maintain, which made it difficult to justify keeping this around considering **how much** of Kibana relies on this setting to be `true` (searching for existing Dashboards by title on the listing page, saving a brand new dashboard, etc.). Therefore, while we still have **some** functionality when `allowExpensiveQueries` is `false`, I have refactored this code significantly to simplify the logic. > [!IMPORTANT] > Specifically, options list controls now only support **exact match searching** when `allowExpensiveQueries` is `false`. Since this query is the same regardless of the type of field or the value of `allowExpensiveQueries`, this means we no longer have to maintain two slightly different versions ("cheap" and "expensive") of our search queries. This cleans up our tech debt significantly. ### Bundle Size Changes @elastic/kibana-operations Changes to bundle size are primarily due to the changes I made to the `OptionsListEditorOptions` component - since this component is directly added to the options list factory (which is not async imported), this impacts the bundle size. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- packages/kbn-optimizer/limits.yml | 2 +- .../controls/common/options_list/ip_search.ts | 4 + .../options_list/is_valid_search.test.ts | 118 +++ .../common/options_list/is_valid_search.ts | 52 ++ .../options_list/suggestions_searching.ts | 31 + .../controls/common/options_list/types.ts | 10 +- .../controls/common/range_slider/mocks.tsx | 46 +- .../control_group/control_group_strings.ts | 46 +- .../editor/control_editor.test.tsx | 154 +++- .../control_group/editor/control_editor.tsx | 180 +++-- .../options_list_editor_options.tsx | 64 +- .../components/options_list_popover.test.tsx | 410 ++++++----- .../options_list_popover_action_bar.tsx | 33 +- .../options_list_popover_empty_message.tsx | 32 +- ...ptions_list_popover_invalid_selections.tsx | 2 +- .../options_list_popover_suggestions.tsx | 33 +- .../components/options_list_strings.ts | 81 +- .../options_list_embeddable_factory.tsx | 2 +- .../options_list/options_list_reducers.ts | 21 +- ...ions_list_cheap_suggestion_queries.test.ts | 690 ------------------ .../options_list_cheap_suggestion_queries.ts | 207 ------ .../options_list_suggestions_route.ts | 27 +- .../options_list/suggestion_queries/index.ts | 9 + .../options_list_all_suggestions.test.ts | 142 ++++ .../options_list_all_suggestions.ts | 90 +++ .../options_list_exact_match.test.ts | 180 +++++ .../options_list_exact_match.ts | 91 +++ .../options_list_search_suggestions.test.ts} | 347 ++------- .../options_list_search_suggestions.ts} | 169 ++--- .../options_list_suggestion_queries.test.ts | 94 +++ .../options_list_suggestion_queries.ts | 32 + .../options_list_suggestion_query_helpers.ts | 6 +- .../controls/common/range_slider.ts | 9 +- .../controls/common/replace_controls.ts | 5 +- ...ptions_list_allow_expensive_queries_off.ts | 8 +- .../options_list_creation_and_editing.ts | 16 +- .../options_list/options_list_suggestions.ts | 84 ++- .../page_objects/dashboard_page_controls.ts | 63 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 41 files changed, 1852 insertions(+), 1741 deletions(-) create mode 100644 src/plugins/controls/common/options_list/is_valid_search.test.ts create mode 100644 src/plugins/controls/common/options_list/is_valid_search.ts create mode 100644 src/plugins/controls/common/options_list/suggestions_searching.ts delete mode 100644 src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts delete mode 100644 src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/index.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.test.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_all_suggestions.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.test.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_exact_match.ts rename src/plugins/controls/server/options_list/{options_list_expensive_suggestion_queries.test.ts => suggestion_queries/options_list_search_suggestions.test.ts} (66%) rename src/plugins/controls/server/options_list/{options_list_expensive_suggestion_queries.ts => suggestion_queries/options_list_search_suggestions.ts} (57%) create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.test.ts create mode 100644 src/plugins/controls/server/options_list/suggestion_queries/options_list_suggestion_queries.ts rename src/plugins/controls/server/options_list/{ => suggestion_queries}/options_list_suggestion_query_helpers.ts (92%) 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": "显示设置",