From a1fe300a0317d5773150a7155c6acc10f9874ef8 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 1 Aug 2024 13:02:18 -0600 Subject: [PATCH] [Embeddable Rebuild] [Controls] Refactor options list control (#186655) Closes https://github.com/elastic/kibana/issues/184374 ## Summary > [!NOTE] > This PR has **no** user-facing changes - all work is contained in the `examples` plugin. ### Design reviewers The `options_list.scss` file in this PR is just a cleaned up / simplified copy of https://github.com/elastic/kibana/blob/main/src/plugins/controls/public/options_list/components/options_list.scss. We are migrating the controls in the examples folder. Once all controls are migrated, we will replace the embeddable controls with the migrated controls from the examples ### Presentation reviewers This PR refactors the options list control to the new React control framework. https://github.com/user-attachments/assets/2fcff028-4408-427e-aa19-7d1e4eaf1e76 ### 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)) ### 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) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/options_list/ip_search.test.ts | 129 ++++++ .../common/options_list/ip_search.ts | 122 ++++++ .../options_list/options_list_selections.ts | 19 + .../suggestions_searching.test.ts | 118 +++++ .../options_list/suggestions_searching.ts | 69 +++ .../options_list/suggestions_sorting.ts | 32 ++ .../public/app/react_control_example.tsx | 26 +- examples/controls_example/public/plugin.tsx | 28 +- .../components/control_panel.tsx | 8 +- .../control_setting_tooltip_label.tsx | 32 ++ .../get_control_group_factory.tsx | 18 + .../react_controls/control_group/types.ts | 1 + .../data_control_editor.test.tsx | 2 +- .../data_controls/data_control_editor.tsx | 31 +- .../data_controls/initialize_data_control.ts | 21 +- .../data_controls/mocks/api_mocks.tsx | 46 ++ ...ta_control_mocks.tsx => factory_mocks.tsx} | 0 .../components/options_list.scss | 90 ++++ .../components/options_list_control.test.tsx | 120 +++++ .../components/options_list_control.tsx | 198 +++++++++ .../options_list_editor_options.test.tsx | 261 +++++++++++ .../options_list_editor_options.tsx | 175 ++++++++ .../components/options_list_popover.test.tsx | 365 ++++++++++++++++ .../components/options_list_popover.tsx | 54 +++ .../options_list_popover_action_bar.tsx | 157 +++++++ .../options_list_popover_empty_message.tsx | 55 +++ .../options_list_popover_footer.tsx | 102 +++++ ...ptions_list_popover_invalid_selections.tsx | 101 +++++ ...tions_list_popover_sorting_button.test.tsx | 121 ++++++ .../options_list_popover_sorting_button.tsx | 160 +++++++ .../options_list_popover_suggestion_badge.tsx | 46 ++ .../options_list_popover_suggestions.tsx | 208 +++++++++ .../options_list_control/constants.ts | 23 + .../fetch_and_validate.tsx | 121 ++++++ .../get_options_list_control_factory.test.tsx | 347 +++++++++++++++ .../get_options_list_control_factory.tsx | 410 ++++++++++++++++++ .../options_list_context_provider.tsx | 34 ++ .../options_list_fetch_cache.ts | 122 ++++++ .../options_list_strings.ts | 323 ++++++++++++++ .../options_list_control/types.ts | 59 +++ .../get_range_slider_control_factory.test.tsx | 72 ++- .../get_range_slider_control_factory.tsx | 29 +- .../data_controls/range_slider/types.ts | 9 - .../get_search_control_factory.tsx | 29 +- .../react_controls/data_controls/types.ts | 36 +- .../react_controls/mocks/control_mocks.ts | 47 ++ .../get_timeslider_control_factory.test.tsx | 17 +- examples/controls_example/tsconfig.json | 1 + .../controls/common/options_list/types.ts | 4 +- 49 files changed, 4464 insertions(+), 134 deletions(-) create mode 100644 examples/controls_example/common/options_list/ip_search.test.ts create mode 100644 examples/controls_example/common/options_list/ip_search.ts create mode 100644 examples/controls_example/common/options_list/options_list_selections.ts create mode 100644 examples/controls_example/common/options_list/suggestions_searching.test.ts create mode 100644 examples/controls_example/common/options_list/suggestions_searching.ts create mode 100644 examples/controls_example/common/options_list/suggestions_sorting.ts create mode 100644 examples/controls_example/public/react_controls/components/control_setting_tooltip_label.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx rename examples/controls_example/public/react_controls/data_controls/mocks/{data_control_mocks.tsx => factory_mocks.tsx} (100%) create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list.scss create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_empty_message.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_invalid_selections.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestion_badge.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/constants.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_fetch_cache.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_strings.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts create mode 100644 examples/controls_example/public/react_controls/mocks/control_mocks.ts diff --git a/examples/controls_example/common/options_list/ip_search.test.ts b/examples/controls_example/common/options_list/ip_search.test.ts new file mode 100644 index 0000000000000..1c935b1875311 --- /dev/null +++ b/examples/controls_example/common/options_list/ip_search.test.ts @@ -0,0 +1,129 @@ +/* + * 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, getIpSegments, getMinMaxIp } from './ip_search'; + +describe('test IP search functionality', () => { + test('get IP segments', () => { + expect(getIpSegments('')).toStrictEqual({ segments: [''], type: 'unknown' }); + expect(getIpSegments('test')).toStrictEqual({ segments: ['test'], type: 'unknown' }); + expect(getIpSegments('123.456')).toStrictEqual({ segments: ['123', '456'], type: 'ipv4' }); + expect(getIpSegments('123..456...')).toStrictEqual({ segments: ['123', '456'], type: 'ipv4' }); + expect(getIpSegments('abc:def:')).toStrictEqual({ segments: ['abc', 'def'], type: 'ipv6' }); + expect(getIpSegments(':::x:::abc:::def:::')).toStrictEqual({ + segments: ['x', 'abc', 'def'], + type: 'ipv6', + }); + }); + + test('get min/max IP', () => { + expect(getMinMaxIp('ipv4', ['123'])).toStrictEqual({ + min: '123.0.0.0', + max: '123.255.255.255', + }); + expect(getMinMaxIp('ipv4', ['123', '456', '789'])).toStrictEqual({ + min: '123.456.789.0', + max: '123.456.789.255', + }); + expect(getMinMaxIp('ipv6', ['abc', 'def'])).toStrictEqual({ + min: 'abc:def::', + max: 'abc:def:ffff:ffff:ffff:ffff:ffff:ffff', + }); + expect(getMinMaxIp('ipv6', ['a', 'b', 'c', 'd', 'e', 'f', 'g'])).toStrictEqual({ + min: 'a:b:c:d:e:f:g::', + max: 'a:b:c:d:e:f:g:ffff', + }); + }); + + test('get IP range query', () => { + // invalid searches + expect(getIpRangeQuery('xyz')).toStrictEqual({ + validSearch: false, + }); + expect(getIpRangeQuery('123.456.OVER 9000')).toStrictEqual({ + validSearch: false, + }); + expect(getIpRangeQuery('abc:def:ghi')).toStrictEqual({ + validSearch: false, + }); + + // full IP searches + expect(getIpRangeQuery('1.2.3.4')).toStrictEqual({ + validSearch: true, + rangeQuery: [ + { + key: 'ipv4', + mask: '1.2.3.4/32', + }, + ], + }); + expect(getIpRangeQuery('1.2.3.256')).toStrictEqual({ + validSearch: false, + rangeQuery: undefined, + }); + expect(getIpRangeQuery('fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42')).toStrictEqual({ + validSearch: true, + rangeQuery: [ + { + key: 'ipv6', + mask: 'fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42/128', + }, + ], + }); + + // partial IP searches - ipv4 + const partialIpv4 = getIpRangeQuery('12.34.'); + expect(partialIpv4.validSearch).toBe(true); + expect(partialIpv4.rangeQuery?.[0]).toStrictEqual({ + key: 'ipv4', + from: '12.34.0.0', + to: '12.34.255.255', + }); + expect(getIpRangeQuery('123.456.7')).toStrictEqual({ + validSearch: false, + rangeQuery: [], + }); + expect(getIpRangeQuery('12:34.56')).toStrictEqual({ + validSearch: false, + rangeQuery: [], + }); + + // partial IP searches - ipv6 + const partialIpv6 = getIpRangeQuery('fbbe:a363:9e14:987c:49cf'); + expect(partialIpv6.validSearch).toBe(true); + expect(partialIpv6.rangeQuery?.[0]).toStrictEqual({ + key: 'ipv6', + from: 'fbbe:a363:9e14:987c:49cf::', + to: 'fbbe:a363:9e14:987c:49cf:ffff:ffff:ffff', + }); + + // partial IP searches - unknown type + let partialUnknownIp = getIpRangeQuery('1234'); + expect(partialUnknownIp.validSearch).toBe(true); + expect(partialUnknownIp.rangeQuery?.length).toBe(1); + expect(partialUnknownIp.rangeQuery?.[0]).toStrictEqual({ + key: 'ipv6', + from: '1234::', + to: '1234:ffff:ffff:ffff:ffff:ffff:ffff:ffff', + }); + + partialUnknownIp = getIpRangeQuery('123'); + expect(partialUnknownIp.validSearch).toBe(true); + expect(partialUnknownIp.rangeQuery?.length).toBe(2); + expect(partialUnknownIp.rangeQuery?.[0]).toStrictEqual({ + key: 'ipv4', + from: '123.0.0.0', + to: '123.255.255.255', + }); + expect(partialUnknownIp.rangeQuery?.[1]).toStrictEqual({ + key: 'ipv6', + from: '123::', + to: '123:ffff:ffff:ffff:ffff:ffff:ffff:ffff', + }); + }); +}); diff --git a/examples/controls_example/common/options_list/ip_search.ts b/examples/controls_example/common/options_list/ip_search.ts new file mode 100644 index 0000000000000..565c2ed2a1df6 --- /dev/null +++ b/examples/controls_example/common/options_list/ip_search.ts @@ -0,0 +1,122 @@ +/* + * 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 ipaddr from 'ipaddr.js'; + +export interface IpRangeQuery { + validSearch: boolean; + rangeQuery?: Array<{ key: string; from: string; to: string } | { key: string; mask: string }>; +} +interface IpSegments { + segments: string[]; + 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 + const ipv4Segments = searchString.split('.').filter((segment) => segment !== ''); + return { segments: ipv4Segments, type: 'ipv4' }; + } else if (searchString.indexOf(':') !== -1) { + // note that currently, because of the logic of splitting here, searching for shorthand IPv6 IPs is not supported (for example, + // must search for `59fb:0:0:0:0:1005:cc57:6571` and not `59fb::1005:cc57:6571` to get the expected match) + const ipv6Segments = searchString.split(':').filter((segment) => segment !== ''); + return { segments: ipv6Segments, type: 'ipv6' }; + } + return { segments: [searchString], type: 'unknown' }; +}; + +export const getMinMaxIp = ( + type: 'ipv4' | 'ipv6', + segments: IpSegments['segments'] +): { min: string; max: string } => { + const isIpv4 = type === 'ipv4'; + const minIp = isIpv4 + ? segments.concat(Array(4 - segments.length).fill('0')).join('.') + : segments.join(':') + '::'; + const maxIp = isIpv4 + ? segments.concat(Array(4 - segments.length).fill('255')).join('.') + : segments.concat(Array(8 - segments.length).fill('ffff')).join(':'); + return { + min: minIp, + max: maxIp, + }; +}; + +const buildFullIpSearchRangeQuery = (segments: IpSegments): IpRangeQuery['rangeQuery'] => { + const { type: ipType, segments: ipSegments } = segments; + + const isIpv4 = ipType === 'ipv4'; + const searchIp = ipSegments.join(isIpv4 ? '.' : ':'); + if (ipaddr.isValid(searchIp)) { + return [ + { + key: ipType, + mask: isIpv4 ? searchIp + '/32' : searchIp + '/128', + }, + ]; + } + return undefined; +}; + +const buildPartialIpSearchRangeQuery = (segments: IpSegments): IpRangeQuery['rangeQuery'] => { + const { type: ipType, segments: ipSegments } = segments; + + const ranges = []; + if (ipType === 'unknown' || ipType === 'ipv4') { + const { min: minIpv4, max: maxIpv4 } = getMinMaxIp('ipv4', ipSegments); + + if (ipaddr.isValid(minIpv4) && ipaddr.isValid(maxIpv4)) { + ranges.push({ + key: 'ipv4', + from: minIpv4, + to: maxIpv4, + }); + } + } + + if (ipType === 'unknown' || ipType === 'ipv6') { + const { min: minIpv6, max: maxIpv6 } = getMinMaxIp('ipv6', ipSegments); + + if (ipaddr.isValid(minIpv6) && ipaddr.isValid(maxIpv6)) { + ranges.push({ + key: 'ipv6', + from: minIpv6, + to: maxIpv6, + }); + } + } + + return ranges; +}; + +export const getIpRangeQuery = (searchString: string): IpRangeQuery => { + if (searchString.match(/^[A-Fa-f0-9.:]*$/) === null) { + return { validSearch: false }; + } + + const ipSegments = getIpSegments(searchString); + if (ipSegments.type === 'ipv4' && ipSegments.segments.length === 4) { + const ipv4RangeQuery = buildFullIpSearchRangeQuery(ipSegments); + return { validSearch: Boolean(ipv4RangeQuery), rangeQuery: ipv4RangeQuery }; + } + if (ipSegments.type === 'ipv6' && ipSegments.segments.length === 8) { + const ipv6RangeQuery = buildFullIpSearchRangeQuery(ipSegments); + return { validSearch: Boolean(ipv6RangeQuery), rangeQuery: ipv6RangeQuery }; + } + + const partialRangeQuery = buildPartialIpSearchRangeQuery(ipSegments); + return { + validSearch: !(partialRangeQuery?.length === 0), + rangeQuery: partialRangeQuery, + }; +}; diff --git a/examples/controls_example/common/options_list/options_list_selections.ts b/examples/controls_example/common/options_list/options_list_selections.ts new file mode 100644 index 0000000000000..1db66f3749e61 --- /dev/null +++ b/examples/controls_example/common/options_list/options_list_selections.ts @@ -0,0 +1,19 @@ +/* + * 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 { DataViewField } from '@kbn/data-views-plugin/common'; + +export type OptionsListSelection = string | number; + +export const getSelectionAsFieldType = ( + field: DataViewField, + key: string +): OptionsListSelection => { + const storeAsNumber = field.type === 'number' || field.type === 'date'; + return storeAsNumber ? +key : key; +}; diff --git a/examples/controls_example/common/options_list/suggestions_searching.test.ts b/examples/controls_example/common/options_list/suggestions_searching.test.ts new file mode 100644 index 0000000000000..fc78b3f97440d --- /dev/null +++ b/examples/controls_example/common/options_list/suggestions_searching.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 './suggestions_searching'; + +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/examples/controls_example/common/options_list/suggestions_searching.ts b/examples/controls_example/common/options_list/suggestions_searching.ts new file mode 100644 index 0000000000000..c4b115e659b0c --- /dev/null +++ b/examples/controls_example/common/options_list/suggestions_searching.ts @@ -0,0 +1,69 @@ +/* + * 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'; + +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 []; + } + } +}; + +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/examples/controls_example/common/options_list/suggestions_sorting.ts b/examples/controls_example/common/options_list/suggestions_sorting.ts new file mode 100644 index 0000000000000..a66fe1bdf2891 --- /dev/null +++ b/examples/controls_example/common/options_list/suggestions_sorting.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 { Direction } from '@elastic/eui'; + +export type OptionsListSortBy = '_count' | '_key'; + +export const OPTIONS_LIST_DEFAULT_SORT: OptionsListSortingType = { + by: '_count', + direction: 'desc', +}; + +export interface OptionsListSortingType { + by: OptionsListSortBy; + direction: Direction; +} + +export const getCompatibleSortingTypes = (type?: string): OptionsListSortBy[] => { + switch (type) { + case 'ip': { + return ['_count']; + } + default: { + return ['_count', '_key']; + } + } +}; diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 7cd1b3e115b28..8e24eb10cbabd 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example.tsx @@ -40,6 +40,7 @@ import { import { toMountPoint } from '@kbn/react-kibana-mount'; import { ControlGroupApi } from '../react_controls/control_group/types'; +import { OPTIONS_LIST_CONTROL_TYPE } from '../react_controls/data_controls/options_list_control/constants'; import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types'; import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types'; import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types'; @@ -58,13 +59,14 @@ const toggleViewButtons = [ }, ]; +const optionsListId = 'optionsList1'; const searchControlId = 'searchControl1'; const rangeSliderControlId = 'rangeSliderControl1'; const timesliderControlId = 'timesliderControl1'; const controlGroupPanels = { [searchControlId]: { type: SEARCH_CONTROL_TYPE, - order: 2, + order: 3, grow: true, width: 'medium', explicitInput: { @@ -92,12 +94,25 @@ const controlGroupPanels = { }, [timesliderControlId]: { type: TIMESLIDER_CONTROL_TYPE, - order: 0, + order: 4, grow: true, width: 'medium', explicitInput: { id: timesliderControlId, - title: 'Time slider', + enhancements: {}, + }, + }, + [optionsListId]: { + type: OPTIONS_LIST_CONTROL_TYPE, + order: 2, + grow: true, + width: 'medium', + explicitInput: { + id: searchControlId, + fieldName: 'agent.keyword', + title: 'Agent', + grow: true, + width: 'medium', enhancements: {}, }, }, @@ -386,6 +401,11 @@ export const ReactControlExample = ({ type: 'index-pattern', id: WEB_LOGS_DATA_VIEW_ID, }, + { + name: `controlGroup_${optionsListId}:optionsListControlDataView`, + type: 'index-pattern', + id: WEB_LOGS_DATA_VIEW_ID, + }, ], }), })} diff --git a/examples/controls_example/public/plugin.tsx b/examples/controls_example/public/plugin.tsx index 64f6686e92c8c..15f587033cb81 100644 --- a/examples/controls_example/public/plugin.tsx +++ b/examples/controls_example/public/plugin.tsx @@ -17,6 +17,7 @@ import { PLUGIN_ID } from './constants'; import img from './control_group_image.png'; import { EditControlAction } from './react_controls/actions/edit_control_action'; import { registerControlFactory } from './react_controls/control_factory_registry'; +import { OPTIONS_LIST_CONTROL_TYPE } from './react_controls/data_controls/options_list_control/constants'; import { RANGE_SLIDER_CONTROL_TYPE } from './react_controls/data_controls/range_slider/types'; import { SEARCH_CONTROL_TYPE } from './react_controls/data_controls/search_control/types'; import { TIMESLIDER_CONTROL_TYPE } from './react_controls/timeslider_control/types'; @@ -50,13 +51,14 @@ export class ControlsExamplePlugin }); }); - registerControlFactory(RANGE_SLIDER_CONTROL_TYPE, async () => { - const [{ getRangesliderControlFactory }, [coreStart, depsStart]] = await Promise.all([ - import('./react_controls/data_controls/range_slider/get_range_slider_control_factory'), + registerControlFactory(OPTIONS_LIST_CONTROL_TYPE, async () => { + const [{ getOptionsListControlFactory }, [coreStart, depsStart]] = await Promise.all([ + import( + './react_controls/data_controls/options_list_control/get_options_list_control_factory' + ), core.getStartServices(), ]); - - return getRangesliderControlFactory({ + return getOptionsListControlFactory({ core: coreStart, data: depsStart.data, dataViews: depsStart.data.dataViews, @@ -72,7 +74,21 @@ export class ControlsExamplePlugin return getSearchEmbeddableFactory({ core: coreStart, - dataViewsService: depsStart.data.dataViews, + data: depsStart.data, + dataViews: depsStart.data.dataViews, + }); + }); + + registerControlFactory(RANGE_SLIDER_CONTROL_TYPE, async () => { + const [{ getRangesliderControlFactory }, [coreStart, depsStart]] = await Promise.all([ + import('./react_controls/data_controls/range_slider/get_range_slider_control_factory'), + core.getStartServices(), + ]); + + return getRangesliderControlFactory({ + core: coreStart, + data: depsStart.data, + dataViews: depsStart.data.dataViews, }); }); diff --git a/examples/controls_example/public/react_controls/components/control_panel.tsx b/examples/controls_example/public/react_controls/components/control_panel.tsx index 7127f158511be..4d858d25aa45b 100644 --- a/examples/controls_example/public/react_controls/components/control_panel.tsx +++ b/examples/controls_example/public/react_controls/components/control_panel.tsx @@ -19,6 +19,7 @@ import { EuiIcon, EuiToolTip, } from '@elastic/eui'; +import { DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { @@ -119,6 +120,7 @@ export const ControlPanel = (activeIndex ?? -1), diff --git a/examples/controls_example/public/react_controls/components/control_setting_tooltip_label.tsx b/examples/controls_example/public/react_controls/components/control_setting_tooltip_label.tsx new file mode 100644 index 0000000000000..91b40e6a95e67 --- /dev/null +++ b/examples/controls_example/public/react_controls/components/control_setting_tooltip_label.tsx @@ -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 React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const ControlSettingTooltipLabel = ({ + label, + tooltip, +}: { + label: string; + tooltip: string; +}) => ( + + {label} + + + + +); diff --git a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx index 84bf9c949210a..64e452aae1180 100644 --- a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx +++ b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx @@ -83,6 +83,7 @@ export const getControlGroupEmbeddableFactory = (services: { const labelPosition$ = new BehaviorSubject( // TODO: Rename `ControlStyle` initialLabelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE` ); + const allowExpensiveQueries$ = new BehaviorSubject(true); /** TODO: Handle loading; loading should be true if any child is loading */ const dataLoading$ = new BehaviorSubject(false); @@ -114,6 +115,7 @@ export const getControlGroupEmbeddableFactory = (services: { ), ignoreParentSettings$, autoApplySelections$, + allowExpensiveQueries$, unsavedChanges, resetUnsavedChanges: () => { // TODO: Implement this @@ -167,6 +169,22 @@ export const getControlGroupEmbeddableFactory = (services: { dataViews.next(newDataViews) ); + /** Fetch the allowExpensiveQuries setting for the children to use if necessary */ + try { + const { allowExpensiveQueries } = await services.core.http.get<{ + allowExpensiveQueries: boolean; + // TODO: Rename this route as part of https://github.com/elastic/kibana/issues/174961 + }>('/internal/controls/optionsList/getExpensiveQueriesSetting', { + version: '1', + }); + if (!allowExpensiveQueries) { + // only set if this returns false, since it defaults to true + allowExpensiveQueries$.next(allowExpensiveQueries); + } + } catch { + // do nothing - default to true on error (which it was initialized to) + } + return { api, Component: () => { diff --git a/examples/controls_example/public/react_controls/control_group/types.ts b/examples/controls_example/public/react_controls/control_group/types.ts index cfbd525dab704..9d1a390125dad 100644 --- a/examples/controls_example/public/react_controls/control_group/types.ts +++ b/examples/controls_example/public/react_controls/control_group/types.ts @@ -58,6 +58,7 @@ export type ControlGroupApi = PresentationContainer & autoApplySelections$: PublishingSubject; controlFetch$: (controlUuid: string) => Observable; ignoreParentSettings$: PublishingSubject; + allowExpensiveQueries$: PublishingSubject; untilInitialized: () => Promise; }; diff --git a/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx b/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx index d90e5349f36d8..5cf4a86752240 100644 --- a/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx @@ -30,7 +30,7 @@ import { getMockedOptionsListControlFactory, getMockedRangeSliderControlFactory, getMockedSearchControlFactory, -} from './mocks/data_control_mocks'; +} from './mocks/factory_mocks'; import { ControlFactory } from '../types'; import { DataControlApi, DefaultDataControlState } from './types'; diff --git a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx index 6776a24e1874d..53a25073375bf 100644 --- a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx @@ -5,7 +5,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { @@ -146,7 +146,7 @@ export const DataControlEditor = ( initialState.controlType ); - const [controlEditorValid, setControlEditorValid] = useState(false); + const [controlOptionsValid, setControlOptionsValid] = useState(true); /** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */ // const editorConfig = controlGroup.getEditorConfig(); @@ -181,19 +181,13 @@ export const DataControlEditor = { - setControlEditorValid( - Boolean(editorState.fieldName) && Boolean(selectedDataView) && Boolean(selectedControlType) - ); - }, [editorState.fieldName, setControlEditorValid, selectedDataView, selectedControlType]); - const CustomSettingsComponent = useMemo(() => { if (!selectedControlType || !editorState.fieldName || !fieldRegistry) return; - const controlFactory = getControlFactory(selectedControlType) as DataControlFactory; const CustomSettings = controlFactory.CustomOptionsComponent; if (!CustomSettings) return; + return ( setEditorState({ ...editorState, ...newState })} - setControlEditorValid={setControlEditorValid} + setControlEditorValid={setControlOptionsValid} + parentApi={controlGroup} /> ); - }, [fieldRegistry, selectedControlType, editorState]); + }, [fieldRegistry, selectedControlType, initialState, editorState, controlGroup]); return ( <> @@ -295,6 +291,8 @@ export const DataControlEditor = @@ -407,7 +405,14 @@ export const DataControlEditor = { onSave(editorState, selectedControlType!); }} diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index 1055047779f71..7c986744c7193 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -10,7 +10,11 @@ import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, first, switchMap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { DataView, DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; +import { + DataView, + DataViewField, + DATA_VIEW_SAVED_OBJECT_TYPE, +} from '@kbn/data-views-plugin/common'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { Filter } from '@kbn/es-query'; import { SerializedPanelState } from '@kbn/presentation-containers'; @@ -21,7 +25,7 @@ import { ControlGroupApi } from '../control_group/types'; import { initializeDefaultControlApi } from '../initialize_default_control_api'; import { ControlApiInitialization, ControlStateManager, DefaultControlState } from '../types'; import { openDataControlEditor } from './open_data_control_editor'; -import { DataControlApi, DefaultDataControlState } from './types'; +import { DataControlApi, DataControlFieldFormatter, DefaultDataControlState } from './types'; export const initializeDataControl = ( controlId: string, @@ -49,6 +53,10 @@ export const initializeDataControl = ( const fieldName = new BehaviorSubject(state.fieldName); const dataViews = new BehaviorSubject(undefined); const filters$ = new BehaviorSubject(undefined); + const field$ = new BehaviorSubject(undefined); + const fieldFormatter = new BehaviorSubject((toFormat: any) => + String(toFormat) + ); const stateManager: ControlStateManager = { ...defaultControl.stateManager, @@ -106,7 +114,13 @@ export const initializeDataControl = ( } else { clearBlockingError(); } + + field$.next(field); defaultPanelTitle.next(field ? field.displayName || field.name : nextFieldName); + const spec = field?.toSpec(); + if (spec) { + fieldFormatter.next(dataView.getFormatterForField(spec).getConverterFor('text')); + } } ); @@ -116,6 +130,7 @@ export const initializeDataControl = ( ...stateManager, ...editorStateManager, } as ControlStateManager; + const initialState = ( Object.keys(mergedStateManager) as Array ).reduce((prev, key) => { @@ -158,6 +173,8 @@ export const initializeDataControl = ( panelTitle, defaultPanelTitle, dataViews, + field$, + fieldFormatter, onEdit, filters$, setOutputFilter: (newFilter: Filter | undefined) => { diff --git a/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx new file mode 100644 index 0000000000000..7f08a96e0ad71 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx @@ -0,0 +1,46 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; + +import { OptionsListSuggestions } from '@kbn/controls-plugin/common/options_list/types'; +import { DataViewField } from '@kbn/data-views-plugin/common'; + +import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; +import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching'; +import { OptionsListSortingType } from '../../../../common/options_list/suggestions_sorting'; +import { OptionsListDisplaySettings } from '../options_list_control/types'; + +export const getOptionsListMocks = () => { + return { + api: { + uuid: 'testControl', + field$: new BehaviorSubject({ type: 'string' } as DataViewField), + availableOptions$: new BehaviorSubject(undefined), + invalidSelections$: new BehaviorSubject>(new Set([])), + totalCardinality$: new BehaviorSubject(undefined), + dataLoading: new BehaviorSubject(false), + parentApi: { + allowExpensiveQueries$: new BehaviorSubject(true), + }, + fieldFormatter: new BehaviorSubject((value: string | number) => String(value)), + makeSelection: jest.fn(), + }, + stateManager: { + searchString: new BehaviorSubject(''), + searchStringValid: new BehaviorSubject(true), + fieldName: new BehaviorSubject('field'), + exclude: new BehaviorSubject(undefined), + existsSelected: new BehaviorSubject(undefined), + sort: new BehaviorSubject(undefined), + selectedOptions: new BehaviorSubject(undefined), + searchTechnique: new BehaviorSubject(undefined), + }, + displaySettings: {} as OptionsListDisplaySettings, + }; +}; diff --git a/examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/factory_mocks.tsx similarity index 100% rename from examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx rename to examples/controls_example/public/react_controls/data_controls/mocks/factory_mocks.tsx diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list.scss b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list.scss new file mode 100644 index 0000000000000..029edd5a8a363 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list.scss @@ -0,0 +1,90 @@ +.optionsList__inputButtonOverride { + max-inline-size: 100% !important; + + .euiButtonEmpty { + border-end-start-radius: 0 !important; + border-start-start-radius: 0 !important; + } +} + +.optionsList--filterBtn { + font-weight: $euiFontWeightRegular !important; + color: $euiTextSubduedColor !important; + + .optionsList--selectionText { + flex-grow: 1; + text-align: left; + } + + .optionsList__selections { + overflow: hidden !important; + } + + .optionsList__filter { + color: $euiTextColor; + font-weight: $euiFontWeightMedium; + } + + .optionsList__filterInvalid { + color: $euiColorWarningText; + } + + .optionsList__negateLabel { + font-weight: $euiFontWeightSemiBold; + font-size: $euiSizeM; + color: $euiColorDanger; + } +} + +.optionsList--sortPopover { + width: $euiSizeXL * 7; +} + +.optionsList__existsFilter { + font-style: italic; + font-weight: $euiFontWeightMedium; +} + +.optionsList__popover { + .optionsList__actions { + padding: 0 $euiSizeS; + border-bottom: $euiBorderThin; + border-color: darken($euiColorLightestShade, 2%); + + .optionsList__searchRow { + padding-top: $euiSizeS + } + + .optionsList__actionsRow { + margin: calc($euiSizeS / 2) 0 !important; + + .optionsList__actionBarDivider { + height: $euiSize; + border-right: $euiBorderThin; + } + } + } + + .optionsList-control-ignored-selection-title { + padding-left: $euiSizeM; + } + + .optionsList__selectionInvalid { + color: $euiColorWarningText; + } + + .optionslist--loadingMoreGroupLabel { + text-align: center; + padding: $euiSizeM; + font-style: italic; + height: $euiSizeXXL !important; + } + + .optionslist--endOfOptionsGroupLabel { + text-align: center; + font-size: $euiSizeM; + height: auto !important; + color: $euiTextSubduedColor; + padding: $euiSizeM; + } +} diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx new file mode 100644 index 0000000000000..c18233d85fc62 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx @@ -0,0 +1,120 @@ +/* + * 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 React from 'react'; + +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { render } from '@testing-library/react'; +import { ControlStateManager } from '../../../types'; +import { getOptionsListMocks } from '../../mocks/api_mocks'; +import { OptionsListControlContext } from '../options_list_context_provider'; +import { OptionsListComponentApi, OptionsListComponentState } from '../types'; +import { OptionsListControl } from './options_list_control'; + +describe('Options list control', () => { + const mountComponent = ({ + api, + displaySettings, + stateManager, + }: { + api: any; + displaySettings: any; + stateManager: any; + }) => { + return render( + , + }} + > + + + ); + }; + + test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => { + const mocks = getOptionsListMocks(); + mocks.api.uuid = 'testExists'; + mocks.stateManager.exclude.next(false); + mocks.stateManager.existsSelected.next(true); + const control = mountComponent(mocks); + const existsOption = control.getByTestId('optionsList-control-testExists'); + expect(existsOption).toHaveTextContent('Exists'); + }); + + test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => { + const mocks = getOptionsListMocks(); + mocks.api.uuid = 'testDoesNotExist'; + mocks.stateManager.exclude.next(true); + mocks.stateManager.existsSelected.next(true); + const control = mountComponent(mocks); + const existsOption = control.getByTestId('optionsList-control-testDoesNotExist'); + expect(existsOption).toHaveTextContent('DOES NOT Exist'); + }); + + describe('renders proper delimiter', () => { + test('keyword field', async () => { + const mocks = getOptionsListMocks(); + mocks.api.uuid = 'testDelimiter'; + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'keyword', + } as DataViewField); + const control = mountComponent(mocks); + const selections = control.getByTestId('optionsListSelections'); + expect(selections.textContent).toBe('woof, bark '); + }); + }); + + test('number field', async () => { + const mocks = getOptionsListMocks(); + mocks.api.uuid = 'testDelimiter'; + mocks.api.availableOptions$.next([ + { value: 1, docCount: 5 }, + { value: 2, docCount: 10 }, + { value: 3, docCount: 12 }, + ]); + mocks.stateManager.selectedOptions.next([1, 2]); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'number', + } as DataViewField); + const control = mountComponent(mocks); + const selections = control.getByTestId('optionsListSelections'); + expect(selections.textContent).toBe('1; 2 '); + }); + + test('should display invalid state', async () => { + const mocks = getOptionsListMocks(); + mocks.api.uuid = 'testInvalid'; + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.api.invalidSelections$.next(new Set(['woof'])); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'number', + } as DataViewField); + + const control = mountComponent(mocks); + expect( + control.queryByTestId('optionsList__invalidSelectionsToken-testInvalid') + ).toBeInTheDocument(); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx new file mode 100644 index 0000000000000..998ca612a34fb --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx @@ -0,0 +1,198 @@ +/* + * 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 { isEmpty } from 'lodash'; +import React, { useMemo, useState } from 'react'; + +import { + EuiFilterButton, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + EuiInputPopover, + EuiToken, + EuiToolTip, + htmlIdGenerator, +} from '@elastic/eui'; +import { + useBatchedOptionalPublishingSubjects, + useBatchedPublishingSubjects, +} from '@kbn/presentation-publishing'; + +import { OptionsListSelection } from '../../../../../common/options_list/options_list_selections'; +import { MIN_POPOVER_WIDTH } from '../constants'; +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListPopover } from './options_list_popover'; +import { OptionsListStrings } from '../options_list_strings'; + +import './options_list.scss'; + +export const OptionsListControl = ({ + controlPanelClassName, +}: { + controlPanelClassName: string; +}) => { + const popoverId = useMemo(() => htmlIdGenerator()(), []); + const { api, stateManager, displaySettings } = useOptionsListContext(); + + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [ + excludeSelected, + existsSelected, + selectedOptions, + invalidSelections, + field, + loading, + panelTitle, + fieldFormatter, + ] = useBatchedPublishingSubjects( + stateManager.exclude, + stateManager.existsSelected, + stateManager.selectedOptions, + api.invalidSelections$, + api.field$, + api.dataLoading, + api.panelTitle, + api.fieldFormatter + ); + const [defaultPanelTitle] = useBatchedOptionalPublishingSubjects(api.defaultPanelTitle); + + const delimiter = useMemo(() => OptionsListStrings.control.getSeparator(field?.type), [field]); + + const { hasSelections, selectionDisplayNode, selectedOptionsCount } = useMemo(() => { + return { + hasSelections: !isEmpty(selectedOptions), + selectedOptionsCount: selectedOptions?.length, + selectionDisplayNode: ( + + +
+ {excludeSelected && ( + <> + + {existsSelected + ? OptionsListStrings.control.getExcludeExists() + : OptionsListStrings.control.getNegate()} + {' '} + + )} + {existsSelected ? ( + + {OptionsListStrings.controlAndPopover.getExists(+Boolean(excludeSelected))} + + ) : ( + <> + {selectedOptions?.length + ? selectedOptions.map((value: OptionsListSelection, i, { length }) => { + const text = `${fieldFormatter(value)}${ + i + 1 === length ? '' : delimiter + } `; + const isInvalid = invalidSelections?.has(value); + return ( + + {text} + + ); + }) + : null} + + )} +
+
+ {invalidSelections && invalidSelections.size > 0 && ( + + + + + + )} +
+ ), + }; + }, [ + selectedOptions, + excludeSelected, + existsSelected, + fieldFormatter, + delimiter, + invalidSelections, + api.uuid, + ]); + + const button = ( + <> + setPopoverOpen(!isPopoverOpen)} + isSelected={isPopoverOpen} + numActiveFilters={selectedOptionsCount} + hasActiveFilters={Boolean(selectedOptionsCount)} + textProps={{ className: 'optionsList--selectionText' }} + aria-label={panelTitle ?? defaultPanelTitle} + aria-expanded={isPopoverOpen} + aria-controls={popoverId} + role="combobox" + > + {hasSelections || existsSelected + ? selectionDisplayNode + : displaySettings.placeholder ?? OptionsListStrings.control.getPlaceholder()} + + + ); + + return ( + + setPopoverOpen(false)} + panelClassName="optionsList__popoverOverride" + panelProps={{ + title: panelTitle ?? defaultPanelTitle, + 'aria-label': OptionsListStrings.popover.getAriaLabel(panelTitle ?? defaultPanelTitle!), + }} + > + + + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.test.tsx new file mode 100644 index 0000000000000..5023a5d276eb5 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.test.tsx @@ -0,0 +1,261 @@ +/* + * 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 React from 'react'; + +import userEvent from '@testing-library/user-event'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { act, render } from '@testing-library/react'; + +import { getMockedControlGroupApi } from '../../../mocks/control_mocks'; +import { CustomOptionsComponentProps, DefaultDataControlState } from '../../types'; +import { OptionsListControlState } from '../types'; +import { OptionsListEditorOptions } from './options_list_editor_options'; +import { ControlGroupApi } from '../../../control_group/types'; +import { BehaviorSubject } from 'rxjs'; + +describe('Options list sorting button', () => { + const getMockedState = ( + overwrite?: Partial + ): State => { + return { + dataViewId: 'testDataViewId', + fieldName: 'fieldName', + ...overwrite, + } as State; + }; + + const updateState = jest.fn(); + const mountComponent = ({ + initialState, + field, + parentApi = getMockedControlGroupApi(), + }: Pick & { + parentApi?: ControlGroupApi; + }) => { + const component = render( + + ); + return component; + }; + + test('run past timeout', () => { + const component = mountComponent({ + initialState: getMockedState({ runPastTimeout: false }), + field: { type: 'string' } as DataViewField, + }); + const toggle = component.getByTestId('optionsListControl__runPastTimeoutAdditionalSetting'); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + userEvent.click(toggle); + expect(updateState).toBeCalledWith({ runPastTimeout: true }); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + }); + + test('selection options', () => { + const component = mountComponent({ + initialState: getMockedState({ singleSelect: true }), + field: { type: 'string' } as DataViewField, + }); + + const multiSelect = component.container.querySelector('input#multi'); + expect(multiSelect).not.toBeChecked(); + expect(component.container.querySelector('input#single')).toBeChecked(); + + userEvent.click(multiSelect!); + expect(updateState).toBeCalledWith({ singleSelect: false }); + expect(multiSelect).toBeChecked(); + expect(component.container.querySelector('input#single')).not.toBeChecked(); + }); + + describe('custom search options', () => { + test('do not show custom search options when `allowExpensiveQueries` is false', async () => { + const allowExpensiveQueries$ = new BehaviorSubject(false); + const controlGroupApi = getMockedControlGroupApi(undefined, { allowExpensiveQueries$ }); + const component = mountComponent({ + initialState: getMockedState(), + field: { type: 'string' } as DataViewField, + parentApi: controlGroupApi, + }); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).not.toBeInTheDocument(); + + act(() => allowExpensiveQueries$.next(true)); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).toBeInTheDocument(); + }); + + test('string field has three custom search options', async () => { + const component = mountComponent({ + initialState: getMockedState(), + field: { type: 'string' } as DataViewField, + }); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).toBeInTheDocument(); + const validTechniques = ['prefix', 'exact', 'wildcard']; + validTechniques.forEach((technique) => { + expect( + component.queryByTestId(`optionsListControl__${technique}SearchOptionAdditionalSetting`) + ).toBeInTheDocument(); + }); + }); + + test('IP field has two custom search options', async () => { + const component = mountComponent({ + initialState: getMockedState(), + field: { type: 'ip' } as DataViewField, + }); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).toBeInTheDocument(); + const validTechniques = ['prefix', 'exact']; + validTechniques.forEach((technique) => { + expect( + component.queryByTestId(`optionsListControl__${technique}SearchOptionAdditionalSetting`) + ).toBeInTheDocument(); + }); + }); + + test('number field does not have custom search options', async () => { + const component = mountComponent({ + initialState: getMockedState(), + field: { type: 'number' } as DataViewField, + }); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).not.toBeInTheDocument(); + }); + + test('date field does not have custom search options', async () => { + const component = mountComponent({ + initialState: getMockedState(), + field: { type: 'date' } as DataViewField, + }); + expect( + component.queryByTestId('optionsListControl__searchOptionsRadioGroup') + ).not.toBeInTheDocument(); + }); + + describe('responds to field type changing', () => { + test('reset back to initial state when valid', async () => { + const initialState = getMockedState({ searchTechnique: 'exact' }); + const parentApi = getMockedControlGroupApi(); + const component = render( + + ); + + /** loads initial state properly */ + expect(component.container.querySelector('input#prefix')).not.toBeChecked(); + expect(component.container.querySelector('input#exact')).toBeChecked(); + expect(component.container.querySelector('input#wildcard')).not.toBeChecked(); + + /** responds to the field type changing */ + component.rerender( + + ); + + expect(updateState).toBeCalledWith({ searchTechnique: 'exact' }); + expect(component.container.querySelector('input#prefix')).not.toBeChecked(); + expect(component.container.querySelector('input#exact')).toBeChecked(); + expect(component.container.querySelector('input#wildcard')).toBeNull(); + }); + + test('if the current selection is valid, send that to the parent editor state', async () => { + const initialState = getMockedState(); + const parentApi = getMockedControlGroupApi(); + const component = render( + + ); + + /** loads default compatible search technique properly */ + expect(component.container.querySelector('input#prefix')).toBeChecked(); + expect(component.container.querySelector('input#exact')).not.toBeChecked(); + expect(component.container.querySelector('input#wildcard')).not.toBeChecked(); + + /** responds to change in search technique */ + const exactSearch = component.container.querySelector('input#exact'); + userEvent.click(exactSearch!); + expect(updateState).toBeCalledWith({ searchTechnique: 'exact' }); + expect(component.container.querySelector('input#prefix')).not.toBeChecked(); + expect(exactSearch).toBeChecked(); + expect(component.container.querySelector('input#wildcard')).not.toBeChecked(); + + /** responds to the field type changing */ + component.rerender( + + ); + + expect(updateState).toBeCalledWith({ searchTechnique: 'exact' }); + }); + + test('if neither the initial or current search technique is valid, revert to the default', async () => { + const initialState = getMockedState({ searchTechnique: 'wildcard' }); + const parentApi = getMockedControlGroupApi(); + const component = render( + + ); + + /** responds to change in search technique */ + const prefixSearch = component.container.querySelector('input#prefix'); + userEvent.click(prefixSearch!); + expect(updateState).toBeCalledWith({ searchTechnique: 'prefix' }); + + /** responds to the field type changing */ + component.rerender( + + ); + + expect(updateState).toBeCalledWith({ searchTechnique: 'exact' }); + }); + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.tsx new file mode 100644 index 0000000000000..3374104c7dabb --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_editor_options.tsx @@ -0,0 +1,175 @@ +/* + * 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 React, { useEffect, useMemo, useState } from 'react'; + +import { EuiFormRow, EuiRadioGroup, EuiSwitch } from '@elastic/eui'; +import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; + +import { + getCompatibleSearchTechniques, + OptionsListSearchTechnique, +} from '../../../../../common/options_list/suggestions_searching'; +import { ControlSettingTooltipLabel } from '../../../components/control_setting_tooltip_label'; +import { CustomOptionsComponentProps } from '../../types'; +import { DEFAULT_SEARCH_TECHNIQUE } from '../constants'; +import { OptionsListControlState } from '../types'; +import { OptionsListStrings } from '../options_list_strings'; + +const selectionOptions = [ + { + id: 'multi', + label: OptionsListStrings.editor.selectionTypes.multi.getLabel(), + 'data-test-subj': 'optionsListControl__multiSearchOptionAdditionalSetting', + }, + { + id: 'single', + label: OptionsListStrings.editor.selectionTypes.single.getLabel(), + 'data-test-subj': 'optionsListControl__singleSearchOptionAdditionalSetting', + }, +]; + +const allSearchOptions = [ + { + id: 'prefix', + label: ( + + ), + 'data-test-subj': 'optionsListControl__prefixSearchOptionAdditionalSetting', + }, + { + id: 'wildcard', + label: ( + + ), + 'data-test-subj': 'optionsListControl__wildcardSearchOptionAdditionalSetting', + }, + { + id: 'exact', + label: ( + + ), + 'data-test-subj': 'optionsListControl__exactSearchOptionAdditionalSetting', + }, +]; + +export const OptionsListEditorOptions = ({ + initialState, + field, + updateState, + parentApi, +}: CustomOptionsComponentProps) => { + const allowExpensiveQueries = useStateFromPublishingSubject(parentApi.allowExpensiveQueries$); + + const [singleSelect, setSingleSelect] = useState(initialState.singleSelect ?? false); + const [runPastTimeout, setRunPastTimeout] = useState( + initialState.runPastTimeout ?? false + ); + const [searchTechnique, setSearchTechnique] = useState( + initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE + ); + + const compatibleSearchTechniques = useMemo( + () => getCompatibleSearchTechniques(field.type), + [field.type] + ); + + const searchOptions = useMemo(() => { + return allSearchOptions.filter((searchOption) => { + return compatibleSearchTechniques.includes(searchOption.id as OptionsListSearchTechnique); + }); + }, [compatibleSearchTechniques]); + + useEffect(() => { + /** + * when field type changes, ensure that the selected search technique is still valid; + * if the selected search technique **isn't** valid, reset it to the default + */ + const initialSearchTechniqueValid = + initialState.searchTechnique && + compatibleSearchTechniques.includes(initialState.searchTechnique); + const currentSearchTechniqueValid = compatibleSearchTechniques.includes(searchTechnique); + + if (initialSearchTechniqueValid) { + // reset back to initial state if possible on field change + setSearchTechnique(initialState.searchTechnique!); + updateState({ searchTechnique: initialState.searchTechnique }); + } else if (currentSearchTechniqueValid) { + // otherwise, if the current selection is valid, send that to the parent editor state + updateState({ searchTechnique }); + } else { + // finally, if neither the initial or current search technique is valid, revert to the default + setSearchTechnique(compatibleSearchTechniques[0]); + updateState({ searchTechnique: compatibleSearchTechniques[0] }); + } + + // Note: We only want to call this when compatible search techniques changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [compatibleSearchTechniques]); + + return ( + <> + + { + const newSingleSelect = id === 'single'; + setSingleSelect(newSingleSelect); + updateState({ singleSelect: newSingleSelect }); + }} + /> + + {allowExpensiveQueries && compatibleSearchTechniques.length > 1 && ( + + { + const newSearchTechnique = id as OptionsListSearchTechnique; + setSearchTechnique(newSearchTechnique); + updateState({ searchTechnique: newSearchTechnique }); + }} + /> + + )} + + + } + checked={runPastTimeout} + onChange={() => { + const newRunPastTimeout = !runPastTimeout; + setRunPastTimeout(newRunPastTimeout); + updateState({ runPastTimeout: newRunPastTimeout }); + }} + data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'} + /> + + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx new file mode 100644 index 0000000000000..05d601e093a4e --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx @@ -0,0 +1,365 @@ +/* + * 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 React from 'react'; + +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { act, render, RenderResult, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { BehaviorSubject } from 'rxjs'; +import { ControlStateManager } from '../../../types'; +import { getOptionsListMocks } from '../../mocks/api_mocks'; +import { OptionsListControlContext } from '../options_list_context_provider'; +import { + OptionsListComponentApi, + OptionsListComponentState, + OptionsListDisplaySettings, +} from '../types'; +import { OptionsListPopover } from './options_list_popover'; + +describe('Options list popover', () => { + const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + + const mountComponent = ({ + api, + displaySettings, + stateManager, + }: { + api: any; + displaySettings: any; + stateManager: any; + }) => { + return render( + , + }} + > + + + ); + }; + + const clickShowOnlySelections = (popover: RenderResult) => { + const showOnlySelectedButton = popover.getByTestId('optionsList-control-show-only-selected'); + userEvent.click(showOnlySelectedButton); + }; + + test('no available options', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([]); + const popover = mountComponent(mocks); + + const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options'); + const noOptionsDiv = within(availableOptionsDiv).getByTestId( + 'optionsList-control-noSelectionsMessage' + ); + expect(noOptionsDiv).toBeInTheDocument(); + }); + + test('clicking options calls `makeSelection`', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + const popover = mountComponent(mocks); + + const existsOption = popover.getByTestId('optionsList-control-selection-exists'); + userEvent.click(existsOption); + expect(mocks.api.makeSelection).toBeCalledWith('exists-option', false); + + let woofOption = popover.getByTestId('optionsList-control-selection-woof'); + userEvent.click(woofOption); + expect(mocks.api.makeSelection).toBeCalledWith('woof', false); + + // simulate `makeSelection` + mocks.stateManager.selectedOptions.next(['woof']); + await waitOneTick(); + + clickShowOnlySelections(popover); + woofOption = popover.getByTestId('optionsList-control-selection-woof'); + userEvent.click(woofOption); + expect(mocks.api.makeSelection).toBeCalledWith('woof', true); + }); + + describe('show only selected', () => { + test('show only selected options', async () => { + const mocks = getOptionsListMocks(); + const selections = ['woof', 'bark']; + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + const popover = mountComponent(mocks); + mocks.stateManager.selectedOptions.next(selections); + await waitOneTick(); + + clickShowOnlySelections(popover); + const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options'); + const availableOptionsList = within(availableOptionsDiv).getByRole('listbox'); + const availableOptions = within(availableOptionsList).getAllByRole('option'); + availableOptions.forEach((child, i) => { + expect(child).toHaveTextContent(`${selections[i]}. Checked option.`); + }); + }); + + test('display error message when the show only selected toggle is true but there are no selections', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + mocks.stateManager.selectedOptions.next([]); + const popover = mountComponent(mocks); + + clickShowOnlySelections(popover); + const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options'); + const noSelectionsDiv = within(availableOptionsDiv).getByTestId( + 'optionsList-control-selectionsEmptyMessage' + ); + expect(noSelectionsDiv).toBeInTheDocument(); + }); + + test('disable search and sort when show only selected toggle is true', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 10 }, + { value: 'meow', docCount: 12 }, + ]); + mocks.stateManager.selectedOptions.next(['woof', 'bark']); + const popover = mountComponent(mocks); + + let searchBox = popover.getByTestId('optionsList-control-search-input'); + let sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton'); + expect(searchBox).not.toBeDisabled(); + expect(sortButton).not.toBeDisabled(); + + clickShowOnlySelections(popover); + searchBox = popover.getByTestId('optionsList-control-search-input'); + sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton'); + expect(searchBox).toBeDisabled(); + expect(sortButton).toBeDisabled(); + }); + }); + + describe('invalid selections', () => { + test('test single invalid selection', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 75 }, + ]); + const popover = mountComponent(mocks); + mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.api.invalidSelections$.next(new Set(['woof'])); + await waitOneTick(); + + const validSelection = popover.getByTestId('optionsList-control-selection-bark'); + expect(validSelection).toHaveTextContent('bark. Checked option.'); + expect( + within(validSelection).getByTestId('optionsList-document-count-badge') + ).toHaveTextContent('75'); + const title = popover.getByTestId('optionList__invalidSelectionLabel'); + expect(title).toHaveTextContent('Invalid selection'); + const invalidSelection = popover.getByTestId('optionsList-control-invalid-selection-woof'); + expect(invalidSelection).toHaveTextContent('woof. Checked option.'); + expect(invalidSelection).toHaveClass('optionsList__selectionInvalid'); + }); + + test('test title when multiple invalid selections', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 75 }, + ]); + mocks.stateManager.selectedOptions.next(['bark', 'woof', 'meow']); + mocks.api.invalidSelections$.next(new Set(['woof', 'meow'])); + const popover = mountComponent(mocks); + + const title = popover.getByTestId('optionList__invalidSelectionLabel'); + expect(title).toHaveTextContent('Invalid selections'); + }); + }); + + describe('include/exclude toggle', () => { + test('should default to exclude = false', async () => { + const mocks = getOptionsListMocks(); + const popover = mountComponent(mocks); + const includeButton = popover.getByTestId('optionsList__includeResults'); + const excludeButton = popover.getByTestId('optionsList__excludeResults'); + expect(includeButton).toHaveAttribute('aria-pressed', 'true'); + expect(excludeButton).toHaveAttribute('aria-pressed', 'false'); + }); + + test('if exclude = true, select appropriate button in button group', async () => { + const mocks = getOptionsListMocks(); + const popover = mountComponent(mocks); + mocks.stateManager.exclude.next(true); + await waitOneTick(); + + const includeButton = popover.getByTestId('optionsList__includeResults'); + const excludeButton = popover.getByTestId('optionsList__excludeResults'); + expect(includeButton).toHaveAttribute('aria-pressed', 'false'); + expect(excludeButton).toHaveAttribute('aria-pressed', 'true'); + }); + }); + + describe('"Exists" option', () => { + test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([]); + const popover = mountComponent(mocks); + + mocks.stateManager.existsSelected.next(false); + await waitOneTick(); + + const existsOption = popover.queryByTestId('optionsList-control-selection-exists'); + expect(existsOption).toBeNull(); + }); + + test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => { + const mocks = getOptionsListMocks(); + mocks.api.availableOptions$.next([ + { value: 'woof', docCount: 5 }, + { value: 'bark', docCount: 75 }, + ]); + const popover = mountComponent(mocks); + + mocks.stateManager.existsSelected.next(true); + await waitOneTick(); + clickShowOnlySelections(popover); + + const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options'); + const availableOptionsList = within(availableOptionsDiv).getByRole('listbox'); + const availableOptions = within(availableOptionsList).getAllByRole('option'); + expect(availableOptions[0]).toHaveTextContent('Exists. Checked option.'); + }); + }); + + describe('field formatter', () => { + const mocks = getOptionsListMocks(); + const mockedFormatter = jest + .fn() + .mockImplementation((value: string | number) => `formatted:${value}`); + mocks.api.fieldFormatter = new BehaviorSubject( + mockedFormatter as (value: string | number) => string + ); + + afterEach(() => { + mockedFormatter.mockClear(); + }); + + test('uses field formatter on suggestions', async () => { + mocks.api.availableOptions$.next([ + { value: 1000, docCount: 1 }, + { value: 123456789, docCount: 4 }, + ]); + mocks.api.field$.next({ + name: 'Test number field', + type: 'number', + } as DataViewField); + const popover = mountComponent(mocks); + + expect(mockedFormatter).toHaveBeenNthCalledWith(1, 1000); + expect(mockedFormatter).toHaveBeenNthCalledWith(2, 123456789); + const options = await popover.findAllByRole('option'); + expect(options[0].textContent).toEqual('Exists'); + expect( + options[1].getElementsByClassName('euiSelectableListItem__text')[0].textContent + ).toEqual('formatted:1000'); + expect( + options[2].getElementsByClassName('euiSelectableListItem__text')[0].textContent + ).toEqual('formatted:123456789'); + }); + + test('converts string to number for date field', async () => { + mocks.api.availableOptions$.next([ + { value: 1721283696000, docCount: 1 }, + { value: 1721295533000, docCount: 2 }, + ]); + mocks.api.field$.next({ + name: 'Test date field', + type: 'date', + } as DataViewField); + + mountComponent(mocks); + expect(mockedFormatter).toHaveBeenNthCalledWith(1, 1721283696000); + expect(mockedFormatter).toHaveBeenNthCalledWith(2, 1721295533000); + }); + }); + + describe('allow expensive queries warning', () => { + test('ensure warning icon does not show up when testAllowExpensiveQueries = true/undefined', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'keyword', + } as DataViewField); + const popover = mountComponent(mocks); + const warning = popover.queryByTestId('optionsList-allow-expensive-queries-warning'); + expect(warning).toBeNull(); + }); + + test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'keyword', + } as DataViewField); + mocks.api.parentApi.allowExpensiveQueries$.next(false); + const popover = mountComponent(mocks); + const warning = popover.getByTestId('optionsList-allow-expensive-queries-warning'); + expect(warning).toBeInstanceOf(HTMLDivElement); + }); + }); + + describe('advanced settings', () => { + const ensureComponentIsHidden = async ({ + displaySettings, + testSubject, + }: { + displaySettings: Partial; + testSubject: string; + }) => { + const mocks = getOptionsListMocks(); + mocks.displaySettings = displaySettings; + const popover = mountComponent(mocks); + const test = popover.queryByTestId(testSubject); + expect(test).toBeNull(); + }; + + test('can hide exists option', async () => { + ensureComponentIsHidden({ + displaySettings: { hideExists: true }, + testSubject: 'optionsList-control-selection-exists', + }); + }); + + test('can hide include/exclude toggle', async () => { + ensureComponentIsHidden({ + displaySettings: { hideExclude: true }, + testSubject: 'optionsList__includeExcludeButtonGroup', + }); + }); + + test('can hide sorting button', async () => { + ensureComponentIsHidden({ + displaySettings: { hideSort: true }, + testSubject: 'optionsListControl__sortingOptionsButton', + }); + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.tsx new file mode 100644 index 0000000000000..1f6168e5de1f2 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.tsx @@ -0,0 +1,54 @@ +/* + * 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 React, { useState } from 'react'; + +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { OptionsListPopoverActionBar } from './options_list_popover_action_bar'; +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListPopoverFooter } from './options_list_popover_footer'; +import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections'; +import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions'; + +export const OptionsListPopover = () => { + const { api, displaySettings } = useOptionsListContext(); + + const [field, availableOptions, invalidSelections, loading] = useBatchedPublishingSubjects( + api.field$, + api.availableOptions$, + api.invalidSelections$, + api.dataLoading + ); + const [showOnlySelected, setShowOnlySelected] = useState(false); + + return ( +
+ {field?.type !== 'boolean' && !displaySettings.hideActionBar && ( + + )} +
+ + {!showOnlySelected && invalidSelections && invalidSelections.size !== 0 && ( + + )} +
+ {!displaySettings.hideExclude && } +
+ ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx new file mode 100644 index 0000000000000..73843ae90d9b3 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx @@ -0,0 +1,157 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { + EuiButtonIcon, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { getCompatibleSearchTechniques } from '../../../../../common/options_list/suggestions_searching'; +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; +import { OptionsListStrings } from '../options_list_strings'; + +interface OptionsListPopoverProps { + showOnlySelected: boolean; + setShowOnlySelected: (value: boolean) => void; +} + +export const OptionsListPopoverActionBar = ({ + showOnlySelected, + setShowOnlySelected, +}: OptionsListPopoverProps) => { + const { api, stateManager, displaySettings } = useOptionsListContext(); + + const [ + searchString, + searchTechnique, + searchStringValid, + invalidSelections, + totalCardinality, + field, + allowExpensiveQueries, + ] = useBatchedPublishingSubjects( + stateManager.searchString, + stateManager.searchTechnique, + stateManager.searchStringValid, + api.invalidSelections$, + api.totalCardinality$, + api.field$, + api.parentApi.allowExpensiveQueries$ + ); + + const compatibleSearchTechniques = useMemo(() => { + if (!field) return []; + return getCompatibleSearchTechniques(field.type); + }, [field]); + + const defaultSearchTechnique = useMemo( + () => searchTechnique ?? compatibleSearchTechniques[0], + [searchTechnique, compatibleSearchTechniques] + ); + + return ( +
+ {compatibleSearchTechniques.length > 0 && ( + + { + stateManager.searchString.next(event.target.value); + }} + value={searchString} + data-test-subj="optionsList-control-search-input" + placeholder={OptionsListStrings.popover.getSearchPlaceholder( + allowExpensiveQueries ? defaultSearchTechnique : 'exact' + )} + /> + + )} + + + {allowExpensiveQueries && ( + + + {OptionsListStrings.popover.getCardinalityLabel(totalCardinality)} + + + )} + {invalidSelections && invalidSelections.size > 0 && ( + <> + {allowExpensiveQueries && ( + +
+ + )} + + + {OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.size)} + + + + )} + + + + + setShowOnlySelected(!showOnlySelected)} + data-test-subj="optionsList-control-show-only-selected" + aria-label={ + showOnlySelected + ? OptionsListStrings.popover.getAllOptionsButtonTitle() + : OptionsListStrings.popover.getSelectedOptionsButtonTitle() + } + /> + + + {!displaySettings.hideSort && ( + + + + )} + + + + +
+ ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_empty_message.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_empty_message.tsx new file mode 100644 index 0000000000000..a6950d2cfd4b0 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_empty_message.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 React, { useMemo } from 'react'; + +import { EuiIcon, EuiSelectableMessage, EuiSpacer } from '@elastic/eui'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListStrings } from '../options_list_strings'; + +export const OptionsListPopoverEmptyMessage = ({ + showOnlySelected, +}: { + showOnlySelected: boolean; +}) => { + const { api, stateManager } = useOptionsListContext(); + + const [searchTechnique, searchStringValid, field] = useBatchedPublishingSubjects( + stateManager.searchTechnique, + stateManager.searchStringValid, + api.field$ + ); + + const noResultsMessage = useMemo(() => { + if (showOnlySelected) { + return OptionsListStrings.popover.getSelectionsEmptyMessage(); + } + if (!searchStringValid && field && searchTechnique) { + return OptionsListStrings.popover.getInvalidSearchMessage(field.type); + } + return OptionsListStrings.popover.getEmptyMessage(); + }, [showOnlySelected, field, searchStringValid, searchTechnique]); + + return ( + + + + {noResultsMessage} + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx new file mode 100644 index 0000000000000..aa38330908762 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx @@ -0,0 +1,102 @@ +/* + * 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 React from 'react'; + +import { + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiPopoverFooter, + EuiProgress, + useEuiBackgroundColor, + useEuiPaddingSize, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListStrings } from '../options_list_strings'; + +const aggregationToggleButtons = [ + { + id: 'optionsList__includeResults', + key: 'optionsList__includeResults', + label: OptionsListStrings.popover.getIncludeLabel(), + }, + { + id: 'optionsList__excludeResults', + key: 'optionsList__excludeResults', + label: OptionsListStrings.popover.getExcludeLabel(), + }, +]; + +export const OptionsListPopoverFooter = () => { + const { api, stateManager } = useOptionsListContext(); + + const [exclude, loading, allowExpensiveQueries] = useBatchedPublishingSubjects( + stateManager.exclude, + api.dataLoading, + api.parentApi.allowExpensiveQueries$ + ); + + return ( + <> + + {loading && ( +
+ +
+ )} + + + + + stateManager.exclude.next(optionId === 'optionsList__excludeResults') + } + buttonSize="compressed" + data-test-subj="optionsList__includeExcludeButtonGroup" + /> + + {!allowExpensiveQueries && ( + + + + )} + +
+ + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_invalid_selections.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_invalid_selections.tsx new file mode 100644 index 0000000000000..19443cc9879f1 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_invalid_selections.tsx @@ -0,0 +1,101 @@ +/* + * 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 React, { useEffect, useState } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiScreenReaderOnly, + EuiSelectable, + EuiSelectableOption, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { + useBatchedPublishingSubjects, + useStateFromPublishingSubject, +} from '@kbn/presentation-publishing'; + +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListStrings } from '../options_list_strings'; + +export const OptionsListPopoverInvalidSelections = () => { + const { api } = useOptionsListContext(); + + const [invalidSelections, fieldFormatter] = useBatchedPublishingSubjects( + api.invalidSelections$, + api.fieldFormatter + ); + const defaultPanelTitle = useStateFromPublishingSubject(api.defaultPanelTitle); + + const [selectableOptions, setSelectableOptions] = useState([]); // will be set in following useEffect + useEffect(() => { + /* This useEffect makes selectableOptions responsive to unchecking options */ + const options: EuiSelectableOption[] = Array.from(invalidSelections).map((key) => { + return { + key: String(key), + label: fieldFormatter(key), + checked: 'on', + className: 'optionsList__selectionInvalid', + 'data-test-subj': `optionsList-control-invalid-selection-${key}`, + prepend: ( + +
+ {OptionsListStrings.popover.getInvalidSelectionScreenReaderText()} + {'" "'} {/* Adds a pause for the screen reader */} +
+
+ ), + }; + }); + setSelectableOptions(options); + }, [fieldFormatter, invalidSelections]); + + return ( + <> + + + + + + + + + + + + { + setSelectableOptions(newSuggestions); + api.deselectOption(changedOption.key); + }} + > + {(list) => list} + + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx new file mode 100644 index 0000000000000..c86aa85b9116e --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 React from 'react'; + +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { render, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ControlStateManager } from '../../../types'; +import { getOptionsListMocks } from '../../mocks/api_mocks'; +import { OptionsListControlContext } from '../options_list_context_provider'; +import { OptionsListComponentApi, OptionsListComponentState } from '../types'; +import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; + +describe('Options list sorting button', () => { + const mountComponent = ({ + api, + displaySettings, + stateManager, + }: { + api: any; + displaySettings: any; + stateManager: any; + }) => { + const component = render( + , + }} + > + + + ); + + // open the popover for testing by clicking on the button + const sortButton = component.getByTestId('optionsListControl__sortingOptionsButton'); + userEvent.click(sortButton); + + return component; + }; + + test('when sorting suggestions, show both sorting types for keyword field', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'keyword', + } as DataViewField); + const component = mountComponent(mocks); + + const sortingOptionsDiv = component.getByTestId('optionsListControl__sortingOptions'); + const optionsText = within(sortingOptionsDiv) + .getAllByRole('option') + .map((el) => el.textContent); + expect(optionsText).toEqual(['By document count. Checked option.', 'Alphabetically']); + }); + + test('sorting popover selects appropriate sorting type on load', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ + name: 'Test keyword field', + type: 'keyword', + } as DataViewField); + mocks.stateManager.sort.next({ by: '_key', direction: 'asc' }); + const component = mountComponent(mocks); + + const sortingOptionsDiv = component.getByTestId('optionsListControl__sortingOptions'); + const optionsText = within(sortingOptionsDiv) + .getAllByRole('option') + .map((el) => el.textContent); + expect(optionsText).toEqual(['By document count', 'Alphabetically. Checked option.']); + + const ascendingButton = component.getByTestId('optionsList__sortOrder_asc'); + expect(ascendingButton).toHaveClass('euiButtonGroupButton-isSelected'); + const descendingButton = component.getByTestId('optionsList__sortOrder_desc'); + expect(descendingButton).not.toHaveClass('euiButtonGroupButton-isSelected'); + }); + + test('when sorting suggestions, only show document count sorting for IP fields', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ name: 'Test IP field', type: 'ip' } as DataViewField); + const component = mountComponent(mocks); + + const sortingOptionsDiv = component.getByTestId('optionsListControl__sortingOptions'); + const optionsText = within(sortingOptionsDiv) + .getAllByRole('option') + .map((el) => el.textContent); + expect(optionsText).toEqual(['By document count. Checked option.']); + }); + + test('when sorting suggestions, show "By date" sorting option for date fields', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ name: 'Test date field', type: 'date' } as DataViewField); + const component = mountComponent(mocks); + + const sortingOptionsDiv = component.getByTestId('optionsListControl__sortingOptions'); + const optionsText = within(sortingOptionsDiv) + .getAllByRole('option') + .map((el) => el.textContent); + expect(optionsText).toEqual(['By document count. Checked option.', 'By date']); + }); + + test('when sorting suggestions, show "Numerically" sorting option for number fields', async () => { + const mocks = getOptionsListMocks(); + mocks.api.field$.next({ name: 'Test number field', type: 'number' } as DataViewField); + const component = mountComponent(mocks); + + const sortingOptionsDiv = component.getByTestId('optionsListControl__sortingOptions'); + const optionsText = within(sortingOptionsDiv) + .getAllByRole('option') + .map((el) => el.textContent); + expect(optionsText).toEqual(['By document count. Checked option.', 'Numerically']); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.tsx new file mode 100644 index 0000000000000..b8a0823dea393 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.tsx @@ -0,0 +1,160 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; + +import { + Direction, + EuiButtonGroup, + EuiButtonGroupOptionProps, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableOption, + EuiToolTip, +} from '@elastic/eui'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { + getCompatibleSortingTypes, + OptionsListSortBy, + OPTIONS_LIST_DEFAULT_SORT, +} from '../../../../../common/options_list/suggestions_sorting'; +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListStrings } from '../options_list_strings'; + +type SortByItem = EuiSelectableOption & { + data: { sortBy: OptionsListSortBy }; +}; + +const sortOrderOptions: EuiButtonGroupOptionProps[] = [ + { + id: 'asc', + iconType: `sortAscending`, + 'data-test-subj': `optionsList__sortOrder_asc`, + label: OptionsListStrings.editorAndPopover.sortOrder.asc.getSortOrderLabel(), + }, + { + id: 'desc', + iconType: `sortDescending`, + 'data-test-subj': `optionsList__sortOrder_desc`, + label: OptionsListStrings.editorAndPopover.sortOrder.desc.getSortOrderLabel(), + }, +]; + +export const OptionsListPopoverSortingButton = ({ + showOnlySelected, +}: { + showOnlySelected: boolean; +}) => { + const { api, stateManager } = useOptionsListContext(); + + const [isSortingPopoverOpen, setIsSortingPopoverOpen] = useState(false); + const [sort, field] = useBatchedPublishingSubjects(stateManager.sort, api.field$); + + const selectedSort = useMemo(() => sort ?? OPTIONS_LIST_DEFAULT_SORT, [sort]); + + const [sortByOptions, setSortByOptions] = useState(() => { + return getCompatibleSortingTypes(field?.type).map((key) => { + return { + onFocusBadge: false, + data: { sortBy: key }, + checked: key === selectedSort.by ? 'on' : undefined, + 'data-test-subj': `optionsList__sortBy_${key}`, + label: OptionsListStrings.editorAndPopover.sortBy[key].getSortByLabel(field?.type), + } as SortByItem; + }); + }); + + const onSortByChange = useCallback( + (updatedOptions: SortByItem[]) => { + setSortByOptions(updatedOptions); + const selectedOption = updatedOptions.find(({ checked }) => checked === 'on'); + if (selectedOption) { + stateManager.sort.next({ + ...selectedSort, + by: selectedOption.data.sortBy, + }); + } + }, + [selectedSort, stateManager.sort] + ); + + const SortButton = () => ( + setIsSortingPopoverOpen(!isSortingPopoverOpen)} + aria-label={OptionsListStrings.popover.getSortPopoverDescription()} + /> + ); + + return ( + + + + } + panelPaddingSize="none" + isOpen={isSortingPopoverOpen} + aria-labelledby="optionsList_sortingOptions" + closePopover={() => setIsSortingPopoverOpen(false)} + panelClassName={'optionsList--sortPopover'} + > + + + + {OptionsListStrings.popover.getSortPopoverTitle()} + + { + stateManager.sort.next({ + ...selectedSort, + direction: value as Direction, + }); + }} + /> + + + + + {(list) => list} + + + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestion_badge.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestion_badge.tsx new file mode 100644 index 0000000000000..79cb490dbf19b --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestion_badge.tsx @@ -0,0 +1,46 @@ +/* + * 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 React from 'react'; + +import { css } from '@emotion/react'; +import { EuiScreenReaderOnly, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; + +import { OptionsListStrings } from '../options_list_strings'; + +export const OptionsListPopoverSuggestionBadge = ({ documentCount }: { documentCount: number }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <> + + + {`${documentCount.toLocaleString()}`} + + + +
+ {'" "'} {/* Adds a pause for the screen reader */} + {OptionsListStrings.popover.getDocumentCountScreenReaderText(documentCount)} +
+
+ + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx new file mode 100644 index 0000000000000..3fdce47271873 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx @@ -0,0 +1,208 @@ +/* + * 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { EuiHighlight, EuiSelectable } from '@elastic/eui'; +import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; +import { OptionsListSuggestions } from '@kbn/controls-plugin/common/options_list/types'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { OptionsListSelection } from '../../../../../common/options_list/options_list_selections'; +import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../constants'; +import { useOptionsListContext } from '../options_list_context_provider'; +import { OptionsListStrings } from '../options_list_strings'; +import { OptionsListPopoverEmptyMessage } from './options_list_popover_empty_message'; +import { OptionsListPopoverSuggestionBadge } from './options_list_popover_suggestion_badge'; + +interface OptionsListPopoverSuggestionsProps { + showOnlySelected: boolean; +} + +export const OptionsListPopoverSuggestions = ({ + showOnlySelected, +}: OptionsListPopoverSuggestionsProps) => { + const { + api, + stateManager, + displaySettings: { hideExists }, + } = useOptionsListContext(); + + const [ + sort, + searchString, + existsSelected, + searchTechnique, + selectedOptions, + fieldName, + invalidSelections, + availableOptions, + totalCardinality, + loading, + fieldFormatter, + allowExpensiveQueries, + ] = useBatchedPublishingSubjects( + stateManager.sort, + stateManager.searchString, + stateManager.existsSelected, + stateManager.searchTechnique, + stateManager.selectedOptions, + stateManager.fieldName, + api.invalidSelections$, + api.availableOptions$, + api.totalCardinality$, + api.dataLoading, + api.fieldFormatter, + api.parentApi.allowExpensiveQueries$ + ); + + const listRef = useRef(null); + + const canLoadMoreSuggestions = useMemo( + () => + allowExpensiveQueries && totalCardinality && !showOnlySelected // && searchString.valid + ? (availableOptions ?? []).length < + Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE) + : false, + [availableOptions, totalCardinality, showOnlySelected, allowExpensiveQueries] + ); + + const suggestions = useMemo(() => { + return (showOnlySelected ? selectedOptions : availableOptions) ?? []; + }, [availableOptions, selectedOptions, showOnlySelected]); + + const existsSelectableOption = useMemo(() => { + if (hideExists || (!existsSelected && (showOnlySelected || suggestions?.length === 0))) return; + + return { + key: 'exists-option', + checked: existsSelected ? 'on' : undefined, + label: OptionsListStrings.controlAndPopover.getExists(), + className: 'optionsList__existsFilter', + 'data-test-subj': 'optionsList-control-selection-exists', + }; + }, [suggestions, existsSelected, showOnlySelected, hideExists]); + + const [selectableOptions, setSelectableOptions] = useState([]); // will be set in following useEffect + useEffect(() => { + /* This useEffect makes selectableOptions responsive to search, show only selected, and clear selections */ + const options: EuiSelectableOption[] = suggestions.map((suggestion) => { + if (typeof suggestion !== 'object') { + // this means that `showOnlySelected` is true, and doc count is not known when this is the case + suggestion = { value: suggestion }; + } + + return { + key: String(suggestion.value), + label: String(fieldFormatter(suggestion.value) ?? suggestion.value), + checked: (selectedOptions ?? []).includes(suggestion.value) ? 'on' : undefined, + 'data-test-subj': `optionsList-control-selection-${suggestion.value}`, + className: + showOnlySelected && invalidSelections.has(suggestion.value) + ? 'optionsList__selectionInvalid' + : 'optionsList__validSuggestion', + append: + !showOnlySelected && suggestion?.docCount ? ( + + ) : undefined, + } as EuiSelectableOption; + }); + + if (canLoadMoreSuggestions) { + options.push({ + key: 'loading-option', + className: 'optionslist--loadingMoreGroupLabel', + label: OptionsListStrings.popover.getLoadingMoreMessage(), + isGroupLabel: true, + }); + } else if (options.length === MAX_OPTIONS_LIST_REQUEST_SIZE) { + options.push({ + key: 'no-more-option', + className: 'optionslist--endOfOptionsGroupLabel', + label: OptionsListStrings.popover.getAtEndOfOptionsMessage(), + isGroupLabel: true, + }); + } + setSelectableOptions(existsSelectableOption ? [existsSelectableOption, ...options] : options); + }, [ + suggestions, + availableOptions, + showOnlySelected, + selectedOptions, + invalidSelections, + existsSelectableOption, + canLoadMoreSuggestions, + fieldFormatter, + ]); + + const loadMoreOptions = useCallback(() => { + const listbox = listRef.current?.querySelector('.euiSelectableList__list'); + if (!listbox) return; + + const { scrollTop, scrollHeight, clientHeight } = listbox; + if (scrollTop + clientHeight >= scrollHeight - parseInt(euiThemeVars.euiSizeXXL, 10)) { + // reached the "bottom" of the list, where euiSizeXXL acts as a "margin of error" so that the user doesn't + // have to scroll **all the way** to the bottom in order to load more options + stateManager.requestSize.next(totalCardinality ?? MAX_OPTIONS_LIST_REQUEST_SIZE); + api.loadMoreSubject.next(null); // trigger refetch with loadMoreSubject + } + }, [api.loadMoreSubject, stateManager.requestSize, totalCardinality]); + + const renderOption = useCallback( + (option, searchStringValue) => { + if (!allowExpensiveQueries || searchTechnique === 'exact') return option.label; + + return ( + + {option.label} + + ); + }, + [searchTechnique, allowExpensiveQueries] + ); + + useEffect(() => { + const container = listRef.current; + if (!loading && canLoadMoreSuggestions) { + container?.addEventListener('scroll', loadMoreOptions, true); + return () => { + container?.removeEventListener('scroll', loadMoreOptions, true); + }; + } + }, [loadMoreOptions, loading, canLoadMoreSuggestions]); + + useEffect(() => { + // scroll back to the top when changing the sorting or the search string + const listbox = listRef.current?.querySelector('.euiSelectableList__list'); + listbox?.scrollTo({ top: 0 }); + }, [sort, searchString]); + + return ( + <> +
+ renderOption(option, searchString)} + listProps={{ onFocusBadge: false }} + aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel( + fieldName, + selectableOptions.length + )} + emptyMessage={} + onChange={(newSuggestions, _, changedOption) => { + setSelectableOptions(newSuggestions); + api.makeSelection(changedOption.key, showOnlySelected); + }} + > + {(list) => list} + +
+ + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/constants.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/constants.ts new file mode 100644 index 0000000000000..6400e7b8efa42 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/constants.ts @@ -0,0 +1,23 @@ +/* + * 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 { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; +import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching'; +import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting'; + +export const OPTIONS_LIST_CONTROL_TYPE = OPTIONS_LIST_CONTROL; +export const DEFAULT_SEARCH_TECHNIQUE: OptionsListSearchTechnique = 'prefix'; +export const OPTIONS_LIST_DEFAULT_SORT: OptionsListSortingType = { + by: '_count', + direction: 'desc', +}; + +export const MIN_POPOVER_WIDTH = 300; + +export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10; +export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx new file mode 100644 index 0000000000000..5ed0d00623706 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx @@ -0,0 +1,121 @@ +/* + * 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 { + BehaviorSubject, + combineLatest, + debounceTime, + Observable, + switchMap, + tap, + withLatestFrom, +} from 'rxjs'; + +import { OptionsListSuccessResponse } from '@kbn/controls-plugin/common/options_list/types'; + +import { isValidSearch } from '../../../../common/options_list/suggestions_searching'; +import { ControlFetchContext } from '../../control_group/control_fetch'; +import { ControlStateManager } from '../../types'; +import { DataControlServices } from '../types'; +import { OptionsListFetchCache } from './options_list_fetch_cache'; +import { OptionsListComponentApi, OptionsListComponentState, OptionsListControlApi } from './types'; + +export function fetchAndValidate$({ + api, + services, + stateManager, +}: { + api: Pick & + Pick & { + controlFetch$: Observable; + loadingSuggestions$: BehaviorSubject; + debouncedSearchString: Observable; + }; + services: DataControlServices; + stateManager: ControlStateManager; +}): Observable { + const requestCache = new OptionsListFetchCache(); + let abortController: AbortController | undefined; + + return combineLatest([ + api.dataViews, + api.field$, + api.controlFetch$, + api.parentApi.allowExpensiveQueries$, + api.debouncedSearchString, + stateManager.sort, + stateManager.searchTechnique, + // cannot use requestSize directly, because we need to be able to reset the size to the default without refetching + api.loadMoreSubject.pipe(debounceTime(100)), // debounce load more so "loading" state briefly shows + ]).pipe( + tap(() => { + // abort any in progress requests + if (abortController) { + abortController.abort(); + abortController = undefined; + } + }), + withLatestFrom( + stateManager.requestSize, + stateManager.runPastTimeout, + stateManager.selectedOptions + ), + switchMap( + async ([ + [ + dataViews, + field, + controlFetchContext, + allowExpensiveQueries, + searchString, + sort, + searchTechnique, + ], + requestSize, + runPastTimeout, + selectedOptions, + ]) => { + const dataView = dataViews?.[0]; + if ( + !dataView || + !field || + !isValidSearch({ searchString, fieldType: field.type, searchTechnique }) + ) { + return { suggestions: [] }; + } + + /** Fetch the suggestions list + perform validation */ + api.loadingSuggestions$.next(true); + + const request = { + sort, + dataView, + searchString, + runPastTimeout, + searchTechnique, + selectedOptions, + field: field.toSpec(), + size: requestSize, + allowExpensiveQueries, + ...controlFetchContext, + }; + + const newAbortController = new AbortController(); + abortController = newAbortController; + try { + return await requestCache.runFetchRequest(request, newAbortController.signal, services); + } catch (error) { + return { error }; + } + } + ), + tap(() => { + api.loadingSuggestions$.next(false); + }) + ); +} diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx new file mode 100644 index 0000000000000..d74070b980769 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx @@ -0,0 +1,347 @@ +/* + * 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 React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks'; +import { getOptionsListControlFactory } from './get_options_list_control_factory'; + +describe('Options List Control Api', () => { + const uuid = 'myControl1'; + const controlGroupApi = getMockedControlGroupApi(); + const mockDataViews = dataViewPluginMocks.createStartContract(); + const mockCore = coreMock.createStart(); + + const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + + mockDataViews.get = jest.fn().mockImplementation(async (id: string): Promise => { + if (id !== 'myDataViewId') { + throw new Error(`Simulated error: no data view found for id ${id}`); + } + const stubDataView = createStubDataView({ + spec: { + id: 'myDataViewId', + fields: { + myFieldName: { + name: 'myFieldName', + customLabel: 'My field name', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + }, + title: 'logstash-*', + timeFieldName: '@timestamp', + }, + }); + stubDataView.getFormatterForField = jest.fn().mockImplementation(() => { + return { + getConverterFor: () => { + return (value: string) => `${value}:formatted`; + }, + toJSON: (value: any) => JSON.stringify(value), + }; + }); + return stubDataView; + }); + + const factory = getOptionsListControlFactory({ + core: mockCore, + data: dataPluginMock.createStartContract(), + dataViews: mockDataViews, + }); + + describe('filters$', () => { + test('should not set filters$ when selectedOptions is not provided', async () => { + const { api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + expect(api.filters$.value).toBeUndefined(); + }); + + test('should set filters$ when selectedOptions is provided', async () => { + const { api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + selectedOptions: ['cool', 'test'], + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + params: ['cool', 'test'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + myFieldName: 'cool', + }, + }, + { + match_phrase: { + myFieldName: 'test', + }, + }, + ], + }, + }, + }, + ]); + }); + + test('should set filters$ when exists is selected', async () => { + const { api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + existsSelected: true, + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + }, + query: { + exists: { + field: 'myFieldName', + }, + }, + }, + ]); + }); + + test('should set filters$ when exclude is selected', async () => { + const { api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + existsSelected: true, + exclude: true, + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + negate: true, + }, + query: { + exists: { + field: 'myFieldName', + }, + }, + }, + ]); + }); + }); + + describe('make selection', () => { + beforeAll(() => { + mockCore.http.fetch = jest.fn().mockResolvedValue({ + suggestions: [ + { value: 'woof', docCount: 10 }, + { value: 'bark', docCount: 15 }, + { value: 'meow', docCount: 12 }, + ], + }); + }); + + test('clicking another option unselects "Exists"', async () => { + const { Component } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + existsSelected: true, + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + + const control = render(); + userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); + await waitFor(() => { + expect(control.getAllByRole('option').length).toBe(4); + }); + + expect(control.getByTestId('optionsList-control-selection-exists')).toBeChecked(); + const option = control.getByTestId('optionsList-control-selection-woof'); + userEvent.click(option); + await waitOneTick(); + expect(control.getByTestId('optionsList-control-selection-exists')).not.toBeChecked(); + expect(option).toBeChecked(); + }); + + test('clicking "Exists" unselects all other selections', async () => { + const { Component } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + selectedOptions: ['woof', 'bark'], + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + + const control = render(); + userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); + await waitFor(() => { + expect(control.getAllByRole('option').length).toEqual(4); + }); + + const existsOption = control.getByTestId('optionsList-control-selection-exists'); + expect(existsOption).not.toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-woof')).toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-bark')).toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-meow')).not.toBeChecked(); + + userEvent.click(existsOption); + await waitOneTick(); + expect(existsOption).toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-woof')).not.toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-bark')).not.toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-meow')).not.toBeChecked(); + }); + + test('deselects when showOnlySelected is true', async () => { + const { Component, api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + selectedOptions: ['woof', 'bark'], + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + + const control = render(); + userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); + await waitFor(() => { + expect(control.getAllByRole('option').length).toEqual(4); + }); + userEvent.click(control.getByTestId('optionsList-control-show-only-selected')); + + expect(control.getByTestId('optionsList-control-selection-woof')).toBeChecked(); + expect(control.getByTestId('optionsList-control-selection-bark')).toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-meow')).toBeNull(); + + userEvent.click(control.getByTestId('optionsList-control-selection-bark')); + await waitOneTick(); + expect(control.getByTestId('optionsList-control-selection-woof')).toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-bark')).toBeNull(); + expect(control.queryByTestId('optionsList-control-selection-meow')).toBeNull(); + + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + }, + query: { + match_phrase: { + myFieldName: 'woof', + }, + }, + }, + ]); + }); + + test('replace selection when singleSelect is true', async () => { + const { Component, api } = await factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + singleSelect: true, + selectedOptions: ['woof'], + }, + getMockedBuildApi(uuid, factory, controlGroupApi), + uuid, + controlGroupApi + ); + + const control = render(); + + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + }, + query: { + match_phrase: { + myFieldName: 'woof', + }, + }, + }, + ]); + + userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); + await waitFor(() => { + expect(control.getAllByRole('option').length).toEqual(4); + }); + expect(control.getByTestId('optionsList-control-selection-woof')).toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-bark')).not.toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-meow')).not.toBeChecked(); + userEvent.click(control.getByTestId('optionsList-control-selection-bark')); + await waitOneTick(); + expect(control.getByTestId('optionsList-control-selection-woof')).not.toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-bark')).toBeChecked(); + expect(control.queryByTestId('optionsList-control-selection-meow')).not.toBeChecked(); + + expect(api.filters$.value).toEqual([ + { + meta: { + index: 'myDataViewId', + key: 'myFieldName', + }, + query: { + match_phrase: { + myFieldName: 'bark', + }, + }, + }, + ]); + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx new file mode 100644 index 0000000000000..22927cadf3cb1 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -0,0 +1,410 @@ +/* + * 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 React, { useEffect } from 'react'; +import deepEqual from 'react-fast-compare'; +import { BehaviorSubject, combineLatest, debounceTime, filter, skip } from 'rxjs'; + +import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching'; +import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting'; +import { + OptionsListSuccessResponse, + OptionsListSuggestions, +} from '@kbn/controls-plugin/common/options_list/types'; +import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { + getSelectionAsFieldType, + OptionsListSelection, +} from '../../../../common/options_list/options_list_selections'; +import { isValidSearch } from '../../../../common/options_list/suggestions_searching'; +import { initializeDataControl } from '../initialize_data_control'; +import { DataControlFactory, DataControlServices } from '../types'; +import { OptionsListControl } from './components/options_list_control'; +import { OptionsListEditorOptions } from './components/options_list_editor_options'; +import { + DEFAULT_SEARCH_TECHNIQUE, + MIN_OPTIONS_LIST_REQUEST_SIZE, + OPTIONS_LIST_CONTROL_TYPE, + OPTIONS_LIST_DEFAULT_SORT, +} from './constants'; +import { fetchAndValidate$ } from './fetch_and_validate'; +import { OptionsListControlContext } from './options_list_context_provider'; +import { OptionsListStrings } from './options_list_strings'; +import { OptionsListControlApi, OptionsListControlState } from './types'; + +export const getOptionsListControlFactory = ( + services: DataControlServices +): DataControlFactory => { + return { + type: OPTIONS_LIST_CONTROL_TYPE, + getIconType: () => 'editorChecklist', + getDisplayName: OptionsListStrings.control.getDisplayName, + isFieldCompatible: (field) => { + return ( + !field.spec.scripted && + field.aggregatable && + ['string', 'boolean', 'ip', 'date', 'number'].includes(field.type) + ); + }, + CustomOptionsComponent: OptionsListEditorOptions, + buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + /** Serializable state - i.e. the state that is saved with the control */ + const searchTechnique$ = new BehaviorSubject( + initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE + ); + const runPastTimeout$ = new BehaviorSubject(initialState.runPastTimeout); + const singleSelect$ = new BehaviorSubject(initialState.singleSelect); + const selections$ = new BehaviorSubject( + initialState.selectedOptions ?? [] + ); + const sort$ = new BehaviorSubject( + initialState.sort ?? OPTIONS_LIST_DEFAULT_SORT + ); + const existsSelected$ = new BehaviorSubject(initialState.existsSelected); + const excludeSelected$ = new BehaviorSubject(initialState.exclude); + + /** Creation options state - cannot currently be changed after creation, but need subjects for comparators */ + const placeholder$ = new BehaviorSubject(initialState.placeholder); + const hideActionBar$ = new BehaviorSubject(initialState.hideActionBar); + const hideExclude$ = new BehaviorSubject(initialState.hideExclude); + const hideExists$ = new BehaviorSubject(initialState.hideExists); + const hideSort$ = new BehaviorSubject(initialState.hideSort); + + /** Runtime / component state - none of this is serialized */ + const searchString$ = new BehaviorSubject(''); + const searchStringValid$ = new BehaviorSubject(true); + const requestSize$ = new BehaviorSubject(MIN_OPTIONS_LIST_REQUEST_SIZE); + + const availableOptions$ = new BehaviorSubject(undefined); + const invalidSelections$ = new BehaviorSubject>(new Set()); + const totalCardinality$ = new BehaviorSubject(0); + + const dataControl = initializeDataControl< + Pick + >( + uuid, + OPTIONS_LIST_CONTROL_TYPE, + initialState, + { searchTechnique: searchTechnique$, singleSelect: singleSelect$ }, + controlGroupApi, + services + ); + + const stateManager = { + ...dataControl.stateManager, + exclude: excludeSelected$, + existsSelected: existsSelected$, + searchTechnique: searchTechnique$, + selectedOptions: selections$, + singleSelect: singleSelect$, + sort: sort$, + searchString: searchString$, + searchStringValid: searchStringValid$, + runPastTimeout: runPastTimeout$, + requestSize: requestSize$, + }; + + /** Handle loading state; since suggestion fetching and validation are tied, only need one loading subject */ + const loadingSuggestions$ = new BehaviorSubject(false); + const dataLoadingSubscription = loadingSuggestions$ + .pipe( + debounceTime(100) // debounce set loading so that it doesn't flash as the user types + ) + .subscribe((isLoading) => { + dataControl.api.setDataLoading(isLoading); + }); + + /** Debounce the search string changes to reduce the number of fetch requests */ + const debouncedSearchString = stateManager.searchString.pipe(debounceTime(100)); + + /** Validate the search string as the user types */ + const validSearchStringSubscription = combineLatest([ + debouncedSearchString, + dataControl.api.field$, + searchTechnique$, + ]).subscribe(([newSearchString, field, searchTechnique]) => { + searchStringValid$.next( + isValidSearch({ + searchString: newSearchString, + fieldType: field?.type, + searchTechnique, + }) + ); + }); + + /** Clear state when the field changes */ + const fieldChangedSubscription = combineLatest([ + dataControl.stateManager.fieldName, + dataControl.stateManager.dataViewId, + ]) + .pipe( + skip(1) // skip first, since this represents initialization + ) + .subscribe(() => { + searchString$.next(''); + selections$.next(undefined); + existsSelected$.next(false); + excludeSelected$.next(false); + requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); + sort$.next(OPTIONS_LIST_DEFAULT_SORT); + }); + + /** Fetch the suggestions and perform validation */ + const loadMoreSubject = new BehaviorSubject(null); + const fetchSubscription = fetchAndValidate$({ + services, + api: { + ...dataControl.api, + loadMoreSubject, + loadingSuggestions$, + debouncedSearchString, + parentApi: controlGroupApi, + controlFetch$: controlGroupApi.controlFetch$(uuid), + }, + stateManager, + }).subscribe((result) => { + // if there was an error during fetch, set blocking error and return early + if (Object.hasOwn(result, 'error')) { + dataControl.api.setBlockingError((result as { error: Error }).error); + return; + } else if (dataControl.api.blockingError.getValue()) { + // otherwise, if there was a previous error, clear it + dataControl.api.setBlockingError(undefined); + } + + // fetch was successful so set all attributes from result + const successResponse = result as OptionsListSuccessResponse; + availableOptions$.next(successResponse.suggestions); + totalCardinality$.next(successResponse.totalCardinality ?? 0); + invalidSelections$.next(new Set(successResponse.invalidSelections ?? [])); + + // reset the request size back to the minimum (if it's not already) + if (stateManager.requestSize.getValue() !== MIN_OPTIONS_LIST_REQUEST_SIZE) { + stateManager.requestSize.next(MIN_OPTIONS_LIST_REQUEST_SIZE); + } + }); + + /** Remove all other selections if this control becomes a single select */ + const singleSelectSubscription = singleSelect$ + .pipe(filter((singleSelect) => Boolean(singleSelect))) + .subscribe(() => { + const currentSelections = selections$.getValue() ?? []; + if (currentSelections.length > 1) selections$.next([currentSelections[0]]); + }); + + /** Output filters when selections change */ + const outputFilterSubscription = combineLatest([ + dataControl.api.dataViews, + dataControl.stateManager.fieldName, + selections$, + existsSelected$, + excludeSelected$, + ]).subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => { + const dataView = dataViews?.[0]; + const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + + if (!dataView || !field) return; + + let newFilter: Filter | undefined; + if (existsSelected) { + newFilter = buildExistsFilter(field, dataView); + } else if (selections && selections.length > 0) { + newFilter = + selections.length === 1 + ? buildPhraseFilter(field, selections[0], dataView) + : buildPhrasesFilter(field, selections, dataView); + } + if (newFilter) { + newFilter.meta.key = field?.name; + if (exclude) newFilter.meta.negate = true; + } + api.setOutputFilter(newFilter); + }); + + const api = buildApi( + { + ...dataControl.api, + getTypeDisplayName: OptionsListStrings.control.getDisplayName, + serializeState: () => { + const { rawState: dataControlState, references } = dataControl.serialize(); + return { + rawState: { + ...dataControlState, + searchTechnique: searchTechnique$.getValue(), + runPastTimeout: runPastTimeout$.getValue(), + singleSelect: singleSelect$.getValue(), + selections: selections$.getValue(), + sort: sort$.getValue(), + existsSelected: existsSelected$.getValue(), + exclude: excludeSelected$.getValue(), + + // serialize state that cannot be changed to keep it consistent + placeholder: placeholder$.getValue(), + hideActionBar: hideActionBar$.getValue(), + hideExclude: hideExclude$.getValue(), + hideExists: hideExists$.getValue(), + hideSort: hideSort$.getValue(), + }, + references, // does not have any references other than those provided by the data control serializer + }; + }, + clearSelections: () => { + if (selections$.getValue()?.length) selections$.next([]); + if (existsSelected$.getValue()) existsSelected$.next(false); + if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); + }, + }, + { + ...dataControl.comparators, + exclude: [excludeSelected$, (selected) => excludeSelected$.next(selected)], + existsSelected: [existsSelected$, (selected) => existsSelected$.next(selected)], + runPastTimeout: [runPastTimeout$, (runPast) => runPastTimeout$.next(runPast)], + searchTechnique: [ + searchTechnique$, + (technique) => searchTechnique$.next(technique), + (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), + ], + selectedOptions: [ + selections$, + (selections) => selections$.next(selections), + (a, b) => deepEqual(a ?? [], b ?? []), + ], + singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], + sort: [ + sort$, + (sort) => sort$.next(sort), + (a, b) => (a ?? OPTIONS_LIST_DEFAULT_SORT) === (b ?? OPTIONS_LIST_DEFAULT_SORT), + ], + + /** This state cannot currently be changed after the control is created */ + placeholder: [placeholder$, (placeholder) => placeholder$.next(placeholder)], + hideActionBar: [hideActionBar$, (hideActionBar) => hideActionBar$.next(hideActionBar)], + hideExclude: [hideExclude$, (hideExclude) => hideExclude$.next(hideExclude)], + hideExists: [hideExists$, (hideExists) => hideExists$.next(hideExists)], + hideSort: [hideSort$, (hideSort) => hideSort$.next(hideSort)], + } + ); + + const componentApi = { + ...api, + selections$, + loadMoreSubject, + totalCardinality$, + availableOptions$, + invalidSelections$, + deselectOption: (key: string | undefined) => { + const field = api.field$.getValue(); + if (!key || !field) { + api.setBlockingError( + new Error(OptionsListStrings.control.getInvalidSelectionMessage()) + ); + return; + } + + const keyAsType = getSelectionAsFieldType(field, key); + + // delete from selections + const selectedOptions = selections$.getValue() ?? []; + const itemIndex = (selections$.getValue() ?? []).indexOf(keyAsType); + if (itemIndex !== -1) { + const newSelections = [...selectedOptions]; + newSelections.splice(itemIndex, 1); + selections$.next(newSelections); + } + // delete from invalid selections + const currentInvalid = invalidSelections$.getValue(); + if (currentInvalid.has(keyAsType)) { + currentInvalid.delete(keyAsType); + invalidSelections$.next(new Set(currentInvalid)); + } + }, + makeSelection: (key: string | undefined, showOnlySelected: boolean) => { + const field = api.field$.getValue(); + if (!key || !field) { + api.setBlockingError( + new Error(OptionsListStrings.control.getInvalidSelectionMessage()) + ); + return; + } + + const existsSelected = Boolean(existsSelected$.getValue()); + const selectedOptions = selections$.getValue() ?? []; + const singleSelect = singleSelect$.getValue(); + + // the order of these checks matters, so be careful if rearranging them + const keyAsType = getSelectionAsFieldType(field, key); + if (key === 'exists-option') { + // if selecting exists, then deselect everything else + existsSelected$.next(!existsSelected); + if (!existsSelected) { + selections$.next([]); + invalidSelections$.next(new Set([])); + } + } else if (showOnlySelected || selectedOptions.includes(keyAsType)) { + componentApi.deselectOption(key); + } else if (singleSelect) { + // replace selection + selections$.next([keyAsType]); + if (existsSelected) existsSelected$.next(false); + } else { + // select option + if (!selectedOptions) selections$.next([]); + if (existsSelected) existsSelected$.next(false); + selections$.next([...selectedOptions, keyAsType]); + } + }, + }; + + if (initialState.selectedOptions?.length || initialState.existsSelected) { + // has selections, so wait for initialization of filters + await dataControl.untilFiltersInitialized(); + } + + return { + api, + Component: ({ className: controlPanelClassName }) => { + useEffect(() => { + return () => { + // on unmount, clean up all subscriptions + dataLoadingSubscription.unsubscribe(); + fetchSubscription.unsubscribe(); + fieldChangedSubscription.unsubscribe(); + outputFilterSubscription.unsubscribe(); + singleSelectSubscription.unsubscribe(); + validSearchStringSubscription.unsubscribe(); + }; + }, []); + + /** Get display settings - if these are ever made editable, should be part of stateManager instead */ + const [placeholder, hideActionBar, hideExclude, hideExists, hideSort] = + useBatchedPublishingSubjects( + placeholder$, + hideActionBar$, + hideExclude$, + hideExists$, + hideSort$ + ); + + return ( + + + + ); + }, + }; + }, + }; +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx new file mode 100644 index 0000000000000..71783210bddfb --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx @@ -0,0 +1,34 @@ +/* + * 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 React, { useContext } from 'react'; + +import { ControlStateManager } from '../../types'; +import { + OptionsListComponentApi, + OptionsListComponentState, + OptionsListDisplaySettings, +} from './types'; + +export const OptionsListControlContext = React.createContext< + | { + api: OptionsListComponentApi; + stateManager: ControlStateManager; + displaySettings: OptionsListDisplaySettings; + } + | undefined +>(undefined); + +export const useOptionsListContext = () => { + const optionsListContext = useContext(OptionsListControlContext); + if (!optionsListContext) + throw new Error( + 'No OptionsListControlContext.Provider found when calling useOptionsListContext.' + ); + return optionsListContext; +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_fetch_cache.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_fetch_cache.ts new file mode 100644 index 0000000000000..84f9d2fc5e206 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_fetch_cache.ts @@ -0,0 +1,122 @@ +/* + * 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 LRUCache from 'lru-cache'; +import hash from 'object-hash'; + +import dateMath from '@kbn/datemath'; + +import { + type OptionsListFailureResponse, + type OptionsListRequest, + type OptionsListResponse, + type OptionsListSuccessResponse, +} from '@kbn/controls-plugin/common/options_list/types'; +import { getEsQueryConfig } from '@kbn/data-plugin/public'; +import { buildEsQuery } from '@kbn/es-query'; +import { DataControlServices } from '../types'; + +const REQUEST_CACHE_SIZE = 50; // only store a max of 50 responses +const REQUEST_CACHE_TTL = 1000 * 60; // time to live = 1 minute + +const optionsListResponseWasFailure = ( + response: OptionsListResponse +): response is OptionsListFailureResponse => { + return (response as OptionsListFailureResponse).error !== undefined; +}; + +export class OptionsListFetchCache { + private cache: LRUCache; + + constructor() { + this.cache = new LRUCache({ + max: REQUEST_CACHE_SIZE, + maxAge: REQUEST_CACHE_TTL, + }); + } + + private getRequestHash = (request: OptionsListRequest) => { + const { + size, + sort, + query, + filters, + timeRange, + searchString, + runPastTimeout, + selectedOptions, + searchTechnique, + field: { name: fieldName }, + dataView: { title: dataViewTitle }, + } = request; + return hash({ + // round timeRange to the minute to avoid cache misses + timeRange: timeRange + ? JSON.stringify({ + from: dateMath.parse(timeRange.from)!.startOf('minute').toISOString(), + to: dateMath.parse(timeRange.to)!.endOf('minute').toISOString(), + }) + : [], + selectedOptions, + filters, + query, + sort, + searchTechnique, + runPastTimeout, + dataViewTitle, + searchString: searchString ?? '', + fieldName, + size, + }); + }; + + public async runFetchRequest( + request: OptionsListRequest, + abortSignal: AbortSignal, + services: DataControlServices + ): Promise { + const requestHash = this.getRequestHash(request); + + if (this.cache.has(requestHash)) { + return Promise.resolve(this.cache.get(requestHash)!); + } else { + const index = request.dataView.getIndexPattern(); + + const timeService = services.data.query.timefilter.timefilter; + const { query, filters, dataView, timeRange, field, ...passThroughProps } = request; + const timeFilter = timeRange ? timeService.createFilter(dataView, timeRange) : undefined; + const filtersToUse = [...(filters ?? []), ...(timeFilter ? [timeFilter] : [])]; + const config = getEsQueryConfig(services.core.uiSettings); + const esFilters = [buildEsQuery(dataView, query ?? [], filtersToUse ?? [], config)]; + + const requestBody = { + ...passThroughProps, + filters: esFilters, + fieldName: field.name, + fieldSpec: field, + runtimeFieldMap: dataView.toSpec?.().runtimeFieldMap, + }; + + const result = await services.core.http.fetch( + `/internal/controls/optionsList/${index}`, + { + version: '1', + body: JSON.stringify(requestBody), + signal: abortSignal, + method: 'POST', + } + ); + + if (!optionsListResponseWasFailure(result)) { + // only add the success responses to the cache + this.cache.set(requestHash, result); + } + return result; + } + } +} diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_strings.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_strings.ts new file mode 100644 index 0000000000000..5bf1c4c92239a --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_strings.ts @@ -0,0 +1,323 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching'; + +export const OptionsListStrings = { + control: { + getDisplayName: () => + i18n.translate('controls.optionsList.displayName', { + defaultMessage: 'Options list', + }), + getSeparator: (type?: string) => { + if (['date', 'number'].includes(type ?? '')) { + return i18n.translate('controls.optionsList.control.dateSeparator', { + defaultMessage: '; ', + }); + } + return i18n.translate('controls.optionsList.control.separator', { + defaultMessage: ', ', + }); + }, + getPlaceholder: () => + i18n.translate('controls.optionsList.control.placeholder', { + defaultMessage: 'Any', + }), + getNegate: () => + i18n.translate('controls.optionsList.control.negate', { + defaultMessage: 'NOT', + }), + getExcludeExists: () => + i18n.translate('controls.optionsList.control.excludeExists', { + defaultMessage: 'DOES NOT', + }), + getInvalidSelectionWarningLabel: (invalidSelectionCount: number) => + i18n.translate('controls.optionsList.control.invalidSelectionWarningLabel', { + defaultMessage: + '{invalidSelectionCount} {invalidSelectionCount, plural, one {selection returns} other {selections return}} no results.', + values: { + invalidSelectionCount, + }, + }), + getInvalidSelectionMessage: () => + i18n.translate('controls.optionsList.popover.selectionError', { + defaultMessage: 'There was an error when making your selection', + }), + }, + editor: { + getSelectionOptionsTitle: () => + i18n.translate('controls.optionsList.editor.selectionOptionsTitle', { + defaultMessage: 'Selections', + }), + selectionTypes: { + multi: { + getLabel: () => + i18n.translate('controls.optionsList.editor.multiSelectLabel', { + defaultMessage: 'Allow multiple selections', + }), + }, + single: { + getLabel: () => + i18n.translate('controls.optionsList.editor.singleSelectLabel', { + defaultMessage: 'Only allow a single selection', + }), + }, + }, + getSearchOptionsTitle: () => + i18n.translate('controls.optionsList.editor.searchOptionsTitle', { + defaultMessage: `Searching`, + }), + searchTypes: { + prefix: { + getLabel: () => + i18n.translate('controls.optionsList.editor.prefixSearchLabel', { + defaultMessage: 'Prefix', + }), + getTooltip: () => + i18n.translate('controls.optionsList.editor.prefixSearchTooltip', { + defaultMessage: 'Matches values that begin with the given search string.', + }), + }, + wildcard: { + getLabel: () => + i18n.translate('controls.optionsList.editor.wildcardSearchLabel', { + defaultMessage: 'Contains', + }), + getTooltip: () => + i18n.translate('controls.optionsList.editor.wildcardSearchTooltip', { + defaultMessage: + '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', { + defaultMessage: `Additional settings`, + }), + getRunPastTimeoutTitle: () => + i18n.translate('controls.optionsList.editor.runPastTimeout', { + defaultMessage: 'Ignore timeout for results', + }), + getRunPastTimeoutTooltip: () => + i18n.translate('controls.optionsList.editor.runPastTimeout.tooltip', { + defaultMessage: + 'Wait to display results until the list is complete. This setting is useful for large data sets, but the results might take longer to populate.', + }), + }, + popover: { + getAriaLabel: (fieldName: string) => + i18n.translate('controls.optionsList.popover.ariaLabel', { + defaultMessage: 'Popover for {fieldName} control', + values: { fieldName }, + }), + getSuggestionsAriaLabel: (fieldName: string, optionCount: number) => + i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', { + defaultMessage: + 'Available {optionCount, plural, one {option} other {options}} for {fieldName}', + values: { fieldName, optionCount }, + }), + getAllowExpensiveQueriesWarning: () => + i18n.translate('controls.optionsList.popover.allowExpensiveQueriesWarning', { + defaultMessage: + 'The cluster setting to allow expensive queries is off, so some features are disabled.', + }), + getLoadingMoreMessage: () => + i18n.translate('controls.optionsList.popover.loadingMore', { + defaultMessage: 'Loading more options...', + }), + getAtEndOfOptionsMessage: () => + i18n.translate('controls.optionsList.popover.endOfOptions', { + defaultMessage: + 'The top 1,000 available options are displayed. View more options by searching for the name.', + }), + getEmptyMessage: () => + i18n.translate('controls.optionsList.popover.empty', { + defaultMessage: 'No options found', + }), + getSelectionsEmptyMessage: () => + 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', + }), + getSelectedOptionsButtonTitle: () => + i18n.translate('controls.optionsList.popover.selectedOptionsTitle', { + defaultMessage: 'Show only selected options', + }), + getSearchPlaceholder: (searchTechnique?: OptionsListSearchTechnique) => { + switch (searchTechnique) { + case 'prefix': { + return i18n.translate('controls.optionsList.popover.prefixSearchPlaceholder', { + defaultMessage: 'Starts with...', + }); + } + 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', { + defaultMessage: + '{totalOptions, number} {totalOptions, plural, one {option} other {options}}', + values: { totalOptions }, + }), + getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) => + i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', { + defaultMessage: + 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName}', + values: { fieldName, invalidSelectionCount }, + }), + getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) => + i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', { + defaultMessage: + 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}}', + values: { invalidSelectionCount }, + }), + getInvalidSelectionsLabel: (selectedOptions: number) => + i18n.translate('controls.optionsList.popover.invalidSelectionsLabel', { + defaultMessage: + '{selectedOptions} {selectedOptions, plural, one {selection} other {selections}} invalid', + values: { selectedOptions }, + }), + getInvalidSelectionScreenReaderText: () => + i18n.translate('controls.optionsList.popover.invalidSelectionScreenReaderText', { + defaultMessage: 'Invalid selection.', + }), + getIncludeLabel: () => + i18n.translate('controls.optionsList.popover.includeLabel', { + defaultMessage: 'Include', + }), + getExcludeLabel: () => + i18n.translate('controls.optionsList.popover.excludeLabel', { + defaultMessage: 'Exclude', + }), + getIncludeExcludeLegend: () => + i18n.translate('controls.optionsList.popover.excludeOptionsLegend', { + defaultMessage: 'Include or exclude selections', + }), + getSortPopoverTitle: () => + i18n.translate('controls.optionsList.popover.sortTitle', { + defaultMessage: 'Sort', + }), + getSortPopoverDescription: () => + i18n.translate('controls.optionsList.popover.sortDescription', { + defaultMessage: 'Define the sort order', + }), + getSortDisabledTooltip: () => + i18n.translate('controls.optionsList.popover.sortDisabledTooltip', { + defaultMessage: 'Sorting is ignored when “Show only selected” is true', + }), + getDocumentCountTooltip: (documentCount: number) => + i18n.translate('controls.optionsList.popover.documentCountTooltip', { + defaultMessage: + 'This value appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', + values: { documentCount }, + }), + getDocumentCountScreenReaderText: (documentCount: number) => + i18n.translate('controls.optionsList.popover.documentCountScreenReaderText', { + defaultMessage: + 'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', + values: { documentCount }, + }), + }, + controlAndPopover: { + getExists: (negate: number = +false) => + i18n.translate('controls.optionsList.controlAndPopover.exists', { + defaultMessage: '{negate, plural, one {Exist} other {Exists}}', + values: { negate }, + }), + }, + editorAndPopover: { + getSortDirectionLegend: () => + i18n.translate('controls.optionsList.popover.sortDirections', { + defaultMessage: 'Sort directions', + }), + sortBy: { + _count: { + getSortByLabel: () => + i18n.translate('controls.optionsList.popover.sortBy.docCount', { + defaultMessage: 'By document count', + }), + }, + _key: { + getSortByLabel: (type?: string) => { + switch (type) { + case 'date': + return i18n.translate('controls.optionsList.popover.sortBy.date', { + defaultMessage: 'By date', + }); + case 'number': + return i18n.translate('controls.optionsList.popover.sortBy.numeric', { + defaultMessage: 'Numerically', + }); + default: + return i18n.translate('controls.optionsList.popover.sortBy.alphabetical', { + defaultMessage: 'Alphabetically', + }); + } + }, + }, + }, + sortOrder: { + asc: { + getSortOrderLabel: () => + i18n.translate('controls.optionsList.popover.sortOrder.asc', { + defaultMessage: 'Ascending', + }), + }, + desc: { + getSortOrderLabel: () => + i18n.translate('controls.optionsList.popover.sortOrder.desc', { + defaultMessage: 'Descending', + }), + }, + }, + }, +}; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts new file mode 100644 index 0000000000000..3fba2f6908d0c --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts @@ -0,0 +1,59 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; + +import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching'; +import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting'; +import { OptionsListSuggestions } from '@kbn/controls-plugin/common/options_list/types'; +import { PublishingSubject } from '@kbn/presentation-publishing'; + +import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; +import { DataControlApi, DefaultDataControlState } from '../types'; + +export interface OptionsListDisplaySettings { + placeholder?: string; + hideActionBar?: boolean; + hideExclude?: boolean; + hideExists?: boolean; + hideSort?: boolean; +} + +export interface OptionsListControlState + extends DefaultDataControlState, + OptionsListDisplaySettings { + searchTechnique?: OptionsListSearchTechnique; + sort?: OptionsListSortingType; + selectedOptions?: OptionsListSelection[]; + existsSelected?: boolean; + runPastTimeout?: boolean; + singleSelect?: boolean; + exclude?: boolean; +} + +export type OptionsListControlApi = DataControlApi; + +export interface OptionsListComponentState + extends Omit { + searchString: string; + searchStringValid: boolean; + requestSize: number; +} + +interface PublishesOptions { + availableOptions$: PublishingSubject; + invalidSelections$: PublishingSubject>; + totalCardinality$: PublishingSubject; +} + +export type OptionsListComponentApi = OptionsListControlApi & + PublishesOptions & { + deselectOption: (key: string | undefined) => void; + makeSelection: (key: string | undefined, showOnlySelected: boolean) => void; + loadMoreSubject: BehaviorSubject; + }; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx index 3746d81f44473..110de033520de 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx @@ -7,22 +7,19 @@ */ import React from 'react'; -import { BehaviorSubject, of } from 'rxjs'; +import { of } from 'rxjs'; import { estypes } from '@elastic/elasticsearch'; import { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { DataViewField } from '@kbn/data-views-plugin/common'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { TimeRange } from '@kbn/es-query'; import { SerializedPanelState } from '@kbn/presentation-containers'; -import { StateComparators } from '@kbn/presentation-publishing'; import { fireEvent, render, waitFor } from '@testing-library/react'; -import { ControlFetchContext } from '../../control_group/control_fetch'; -import { ControlGroupApi } from '../../control_group/types'; -import { ControlApiRegistration } from '../../types'; +import { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks'; import { getRangesliderControlFactory } from './get_range_slider_control_factory'; -import { RangesliderControlApi, RangesliderControlState } from './types'; +import { RangesliderControlState } from './types'; const DEFAULT_TOTAL_RESULTS = 20; const DEFAULT_MIN = 0; @@ -30,14 +27,9 @@ const DEFAULT_MAX = 1000; describe('RangesliderControlApi', () => { const uuid = 'myControl1'; - const dashboardApi = { - timeRange$: new BehaviorSubject(undefined), - }; - const controlGroupApi = { - controlFetch$: () => new BehaviorSubject({}), - ignoreParentSettings$: new BehaviorSubject(undefined), - parentApi: dashboardApi, - } as unknown as ControlGroupApi; + + const controlGroupApi = getMockedControlGroupApi(); + const dataStartServiceMock = dataPluginMock.createStartContract(); let totalResults = DEFAULT_TOTAL_RESULTS; let min: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MIN; @@ -62,8 +54,8 @@ describe('RangesliderControlApi', () => { }; }); const mockDataViews = dataViewPluginMocks.createStartContract(); - // @ts-ignore - mockDataViews.get = async (id: string): Promise => { + + mockDataViews.get = jest.fn().mockImplementation(async (id: string): Promise => { if (id !== 'myDataViewId') { throw new Error(`no data view found for id ${id}`); } @@ -74,7 +66,8 @@ describe('RangesliderControlApi', () => { { displayName: 'My field name', name: 'myFieldName', - type: 'string', + type: 'number', + toSpec: jest.fn(), }, ].find((field) => fieldName === field.name); }, @@ -86,7 +79,8 @@ describe('RangesliderControlApi', () => { }; }, } as unknown as DataView; - }; + }); + const factory = getRangesliderControlFactory({ core: coreMock.createStart(), data: dataStartServiceMock, @@ -99,28 +93,14 @@ describe('RangesliderControlApi', () => { max = DEFAULT_MAX; }); - function buildApiMock( - api: ControlApiRegistration, - nextComparitors: StateComparators - ) { - return { - ...api, - uuid, - parentApi: controlGroupApi, - unsavedChanges: new BehaviorSubject | undefined>(undefined), - resetUnsavedChanges: () => {}, - type: factory.type, - }; - } - - describe('on initialize', () => { + describe('filters$', () => { test('should not set filters$ when value is not provided', async () => { const { api } = await factory.buildControl( { dataViewId: 'myDataView', fieldName: 'myFieldName', }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -134,7 +114,7 @@ describe('RangesliderControlApi', () => { fieldName: 'myFieldName', value: ['5', '10'], }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -169,7 +149,7 @@ describe('RangesliderControlApi', () => { fieldName: 'myFieldName', value: ['5', '10'], }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -191,7 +171,7 @@ describe('RangesliderControlApi', () => { fieldName: 'myFieldName', value: ['5', '10'], }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -209,7 +189,7 @@ describe('RangesliderControlApi', () => { dataViewId: 'myDataViewId', fieldName: 'myFieldName', }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -230,7 +210,7 @@ describe('RangesliderControlApi', () => { dataViewId: 'myDataViewId', fieldName: 'myFieldName', }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -245,7 +225,7 @@ describe('RangesliderControlApi', () => { fieldName: 'myFieldName', step: 1024, }, - buildApiMock, + getMockedBuildApi(uuid, factory, controlGroupApi), uuid, controlGroupApi ); @@ -259,9 +239,11 @@ describe('RangesliderControlApi', () => { const CustomSettings = factory.CustomOptionsComponent!; const component = render( ); expect( @@ -274,9 +256,11 @@ describe('RangesliderControlApi', () => { const CustomSettings = factory.CustomOptionsComponent!; const component = render( ); @@ -285,7 +269,7 @@ describe('RangesliderControlApi', () => { }); expect(setControlEditorValid).toBeCalledWith(false); fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { - target: { value: '' }, + target: { value: undefined }, }); expect(setControlEditorValid).toBeCalledWith(false); fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 4db429ba7cb68..6fc95bdf1913e 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { BehaviorSubject, combineLatest, map, skip } from 'rxjs'; import { initializeDataControl } from '../initialize_data_control'; -import { DataControlFactory } from '../types'; +import { DataControlFactory, DataControlServices } from '../types'; import { RangeSliderControl } from './components/range_slider_control'; import { hasNoResults$ } from './has_no_results'; import { minMax$ } from './min_max'; @@ -22,11 +22,10 @@ import { RangesliderControlState, RangeValue, RANGE_SLIDER_CONTROL_TYPE, - Services, } from './types'; export const getRangesliderControlFactory = ( - services: Services + services: DataControlServices ): DataControlFactory => { return { type: RANGE_SLIDER_CONTROL_TYPE, @@ -35,8 +34,9 @@ export const getRangesliderControlFactory = ( isFieldCompatible: (field) => { return field.aggregatable && field.type === 'number'; }, - CustomOptionsComponent: ({ currentState, updateState, setControlEditorValid }) => { - const step = currentState.step ?? 1; + CustomOptionsComponent: ({ initialState, updateState, setControlEditorValid }) => { + const [step, setStep] = useState(initialState.step ?? 1); + return ( <> @@ -44,6 +44,7 @@ export const getRangesliderControlFactory = ( value={step} onChange={(event) => { const newStep = event.target.valueAsNumber; + setStep(newStep); updateState({ step: newStep }); setControlEditorValid(newStep > 0); }} @@ -207,11 +208,10 @@ export const getRangesliderControlFactory = ( return { api, Component: ({ className: controlPanelClassName }) => { - const [dataLoading, dataViews, fieldName, max, min, selectionHasNotResults, step, value] = + const [dataLoading, fieldFormatter, max, min, selectionHasNotResults, step, value] = useBatchedPublishingSubjects( dataLoading$, - dataControl.api.dataViews, - dataControl.stateManager.fieldName, + dataControl.api.fieldFormatter, max$, min$, selectionHasNoResults$, @@ -229,17 +229,6 @@ export const getRangesliderControlFactory = ( }; }, []); - const fieldFormatter = useMemo(() => { - const dataView = dataViews?.[0]; - if (!dataView) { - return undefined; - } - const fieldSpec = dataView.getFieldByName(fieldName); - return fieldSpec - ? dataView.getFormatterForField(fieldSpec).getConverterFor('text') - : undefined; - }, [dataViews, fieldName]); - return ( => { +export const getSearchControlFactory = ( + services: DataControlServices +): DataControlFactory => { return { type: SEARCH_CONTROL_TYPE, getIconType: () => 'search', @@ -65,8 +59,11 @@ export const getSearchControlFactory = ({ (field.spec.esTypes ?? []).includes('text') ); }, - CustomOptionsComponent: ({ currentState, updateState }) => { - const searchTechnique = currentState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE; + CustomOptionsComponent: ({ initialState, updateState }) => { + const [searchTechnique, setSearchTechnique] = useState( + initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE + ); + return ( { const newSearchTechnique = id as SearchControlTechniques; + setSearchTechnique(newSearchTechnique); updateState({ searchTechnique: newSearchTechnique }); }} /> @@ -93,10 +91,7 @@ export const getSearchControlFactory = ({ initialState, editorStateManager, parentApi, - { - core, - dataViews: dataViewsService, - } + services ); const api = buildApi( diff --git a/examples/controls_example/public/react_controls/data_controls/types.ts b/examples/controls_example/public/react_controls/data_controls/types.ts index b3379889f4223..db4cba8773232 100644 --- a/examples/controls_example/public/react_controls/data_controls/types.ts +++ b/examples/controls_example/public/react_controls/data_controls/types.ts @@ -6,34 +6,54 @@ * Side Public License, v 1. */ +import { CoreStart } from '@kbn/core/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewField } from '@kbn/data-views-plugin/common'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { Filter } from '@kbn/es-query'; +import { FieldFormatConvertFunction } from '@kbn/field-formats-plugin/common'; import { HasEditCapabilities, PublishesDataViews, PublishesFilters, PublishesPanelTitle, + PublishingSubject, } from '@kbn/presentation-publishing'; +import { ControlGroupApi } from '../control_group/types'; import { ControlFactory, DefaultControlApi, DefaultControlState } from '../types'; +export type DataControlFieldFormatter = FieldFormatConvertFunction | ((toFormat: any) => string); + +export interface PublishesField { + field$: PublishingSubject; + fieldFormatter: PublishingSubject; +} + export type DataControlApi = DefaultControlApi & Omit & // control titles cannot be hidden HasEditCapabilities & PublishesDataViews & + PublishesField & PublishesFilters & { setOutputFilter: (filter: Filter | undefined) => void; // a control should only ever output a **single** filter }; +export interface CustomOptionsComponentProps< + State extends DefaultDataControlState = DefaultDataControlState +> { + initialState: Omit; + field: DataViewField; + updateState: (newState: Partial) => void; + setControlEditorValid: (valid: boolean) => void; + parentApi: ControlGroupApi; +} + export interface DataControlFactory< State extends DefaultDataControlState = DefaultDataControlState, Api extends DataControlApi = DataControlApi > extends ControlFactory { isFieldCompatible: (field: DataViewField) => boolean; - CustomOptionsComponent?: React.FC<{ - currentState: Partial; - updateState: (newState: Partial) => void; - setControlEditorValid: (valid: boolean) => void; - }>; + CustomOptionsComponent?: React.FC>; } export const isDataControlFactory = ( @@ -47,3 +67,9 @@ export interface DefaultDataControlState extends DefaultControlState { fieldName: string; title?: string; // custom control label } + +export interface DataControlServices { + core: CoreStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; +} diff --git a/examples/controls_example/public/react_controls/mocks/control_mocks.ts b/examples/controls_example/public/react_controls/mocks/control_mocks.ts new file mode 100644 index 0000000000000..fb484013f7ccc --- /dev/null +++ b/examples/controls_example/public/react_controls/mocks/control_mocks.ts @@ -0,0 +1,47 @@ +/* + * 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 { TimeRange } from '@kbn/es-query'; +import { PublishesUnifiedSearch, StateComparators } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import { ControlFetchContext } from '../control_group/control_fetch/control_fetch'; +import { ControlGroupApi } from '../control_group/types'; +import { ControlApiRegistration, ControlFactory, DefaultControlApi } from '../types'; + +export const getMockedControlGroupApi = ( + dashboardApi: Partial = { + timeRange$: new BehaviorSubject(undefined), + }, + overwriteApi?: Partial +) => { + return { + parentApi: dashboardApi, + autoApplySelections$: new BehaviorSubject(true), + ignoreParentSettings$: new BehaviorSubject(undefined), + controlFetch$: () => new BehaviorSubject({}), + allowExpensiveQueries$: new BehaviorSubject(true), + ...overwriteApi, + } as unknown as ControlGroupApi; +}; + +export const getMockedBuildApi = + ( + uuid: string, + factory: ControlFactory, + controlGroupApi?: ControlGroupApi + ) => + (api: ControlApiRegistration, nextComparators: StateComparators) => { + return { + ...api, + uuid, + parentApi: controlGroupApi ?? getMockedControlGroupApi(), + unsavedChanges: new BehaviorSubject | undefined>(undefined), + resetUnsavedChanges: () => {}, + type: factory.type, + }; + }; diff --git a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.test.tsx b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.test.tsx index ff32abe441a0c..9bf8ade2ebe32 100644 --- a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.test.tsx +++ b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.test.tsx @@ -6,28 +6,27 @@ * Side Public License, v 1. */ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { TimeRange } from '@kbn/es-query'; -import { StateComparators } from '@kbn/presentation-publishing'; import { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import dateMath from '@kbn/datemath'; +import { TimeRange } from '@kbn/es-query'; +import { StateComparators } from '@kbn/presentation-publishing'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; import { BehaviorSubject } from 'rxjs'; -import { ControlGroupApi } from '../control_group/types'; +import { getMockedControlGroupApi } from '../mocks/control_mocks'; import { ControlApiRegistration } from '../types'; import { getTimesliderControlFactory } from './get_timeslider_control_factory'; import { TimesliderControlApi, TimesliderControlState } from './types'; describe('TimesliderControlApi', () => { const uuid = 'myControl1'; + const dashboardApi = { timeRange$: new BehaviorSubject(undefined), }; - const controlGroupApi = { - autoApplySelections$: new BehaviorSubject(true), - parentApi: dashboardApi, - } as unknown as ControlGroupApi; + const controlGroupApi = getMockedControlGroupApi(dashboardApi); + const dataStartServiceMock = dataPluginMock.createStartContract(); dataStartServiceMock.query.timefilter.timefilter.calculateBounds = (timeRange: TimeRange) => { const now = new Date(); diff --git a/examples/controls_example/tsconfig.json b/examples/controls_example/tsconfig.json index 5ad45877cf0a7..9ddcdf1835213 100644 --- a/examples/controls_example/tsconfig.json +++ b/examples/controls_example/tsconfig.json @@ -36,5 +36,6 @@ "@kbn/datemath", "@kbn/ui-theme", "@kbn/react-kibana-context-render", + "@kbn/field-formats-plugin", ] } diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index 1242e866ca089..4192cc6aac26d 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -7,7 +7,7 @@ */ import { DataView, FieldSpec, RuntimeFieldSpec } from '@kbn/data-views-plugin/common'; -import type { BoolQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { AggregateQuery, BoolQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import type { DataControlInput } from '../types'; import { OptionsListSelection } from './options_list_selections'; @@ -68,7 +68,7 @@ export type OptionsListRequest = Omit< dataView: DataView; filters?: Filter[]; field: FieldSpec; - query?: Query; + query?: Query | AggregateQuery; }; /**