[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>
This commit is contained in:
Hannah Mudge 2024-08-01 13:02:18 -06:00 committed by GitHub
parent 5c9853dc57
commit a1fe300a03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 4464 additions and 134 deletions

View file

@ -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',
});
});
});

View file

@ -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,
};
};

View file

@ -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;
};

View file

@ -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);
});
});
});

View file

@ -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;
}
}
};

View file

@ -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'];
}
}
};

View file

@ -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,
},
],
}),
})}

View file

@ -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,
});
});

View file

@ -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 = <ApiType extends DefaultControlApi = DefaultControlA
const viewMode = (rawViewMode ?? ViewMode.VIEW) as ViewMode;
const isEditable = viewMode === ViewMode.EDIT;
const controlWidth = width ?? DEFAULT_CONTROL_WIDTH;
return (
<EuiFlexItem
@ -130,9 +132,9 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
data-render-complete="true"
className={classNames('controlFrameWrapper', {
'controlFrameWrapper--grow': grow,
'controlFrameWrapper--small': width === 'small',
'controlFrameWrapper--medium': width === 'medium',
'controlFrameWrapper--large': width === 'large',
'controlFrameWrapper--small': controlWidth === 'small',
'controlFrameWrapper--medium': controlWidth === 'medium',
'controlFrameWrapper--large': controlWidth === 'large',
'controlFrameWrapper-isDragging': isDragging,
'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (activeIndex ?? -1),
'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (activeIndex ?? -1),

View file

@ -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;
}) => (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>{label}</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip content={tooltip} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -83,6 +83,7 @@ export const getControlGroupEmbeddableFactory = (services: {
const labelPosition$ = new BehaviorSubject<ControlStyle>( // TODO: Rename `ControlStyle`
initialLabelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE`
);
const allowExpensiveQueries$ = new BehaviorSubject<boolean>(true);
/** TODO: Handle loading; loading should be true if any child is loading */
const dataLoading$ = new BehaviorSubject<boolean | undefined>(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: () => {

View file

@ -58,6 +58,7 @@ export type ControlGroupApi = PresentationContainer &
autoApplySelections$: PublishingSubject<boolean>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
allowExpensiveQueries$: PublishingSubject<boolean>;
untilInitialized: () => Promise<void>;
};

View file

@ -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';

View file

@ -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 = <State extends DataControlEditorState = DataCon
const [selectedControlType, setSelectedControlType] = useState<string | undefined>(
initialState.controlType
);
const [controlEditorValid, setControlEditorValid] = useState<boolean>(false);
const [controlOptionsValid, setControlOptionsValid] = useState<boolean>(true);
/** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */
// const editorConfig = controlGroup.getEditorConfig();
@ -181,19 +181,13 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
};
}, [editorState.dataViewId]);
useEffect(() => {
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 (
<EuiDescribedFormGroup
ratio="third"
@ -210,13 +204,15 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
data-test-subj="control-editor-custom-settings"
>
<CustomSettings
currentState={editorState}
initialState={initialState}
field={fieldRegistry[editorState.fieldName].field}
updateState={(newState) => setEditorState({ ...editorState, ...newState })}
setControlEditorValid={setControlEditorValid}
setControlEditorValid={setControlOptionsValid}
parentApi={controlGroup}
/>
</EuiDescribedFormGroup>
);
}, [fieldRegistry, selectedControlType, editorState]);
}, [fieldRegistry, selectedControlType, initialState, editorState, controlGroup]);
return (
<>
@ -295,6 +291,8 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
if (!currentTitle || currentTitle === newDefaultTitle) {
setPanelTitle(newDefaultTitle);
}
setControlOptionsValid(true); // reset options state
}}
selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }}
/>
@ -407,7 +405,14 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
data-test-subj="control-editor-save"
iconType="check"
color="primary"
disabled={!controlEditorValid}
disabled={
!(
controlOptionsValid &&
Boolean(editorState.fieldName) &&
Boolean(selectedDataView) &&
Boolean(selectedControlType)
)
}
onClick={() => {
onSave(editorState, selectedControlType!);
}}

View file

@ -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 = <EditorState extends object = {}>(
controlId: string,
@ -49,6 +53,10 @@ export const initializeDataControl = <EditorState extends object = {}>(
const fieldName = new BehaviorSubject<string>(state.fieldName);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const filters$ = new BehaviorSubject<Filter[] | undefined>(undefined);
const field$ = new BehaviorSubject<DataViewField | undefined>(undefined);
const fieldFormatter = new BehaviorSubject<DataControlFieldFormatter>((toFormat: any) =>
String(toFormat)
);
const stateManager: ControlStateManager<DefaultDataControlState> = {
...defaultControl.stateManager,
@ -106,7 +114,13 @@ export const initializeDataControl = <EditorState extends object = {}>(
} 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 = <EditorState extends object = {}>(
...stateManager,
...editorStateManager,
} as ControlStateManager<DefaultDataControlState & EditorState>;
const initialState = (
Object.keys(mergedStateManager) as Array<keyof DefaultDataControlState & EditorState>
).reduce((prev, key) => {
@ -158,6 +173,8 @@ export const initializeDataControl = <EditorState extends object = {}>(
panelTitle,
defaultPanelTitle,
dataViews,
field$,
fieldFormatter,
onEdit,
filters$,
setOutputFilter: (newFilter: Filter | undefined) => {

View file

@ -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<DataViewField | undefined>({ type: 'string' } as DataViewField),
availableOptions$: new BehaviorSubject<OptionsListSuggestions | undefined>(undefined),
invalidSelections$: new BehaviorSubject<Set<OptionsListSelection>>(new Set([])),
totalCardinality$: new BehaviorSubject<number | undefined>(undefined),
dataLoading: new BehaviorSubject<boolean>(false),
parentApi: {
allowExpensiveQueries$: new BehaviorSubject<boolean>(true),
},
fieldFormatter: new BehaviorSubject((value: string | number) => String(value)),
makeSelection: jest.fn(),
},
stateManager: {
searchString: new BehaviorSubject<string>(''),
searchStringValid: new BehaviorSubject<boolean>(true),
fieldName: new BehaviorSubject<string>('field'),
exclude: new BehaviorSubject<boolean | undefined>(undefined),
existsSelected: new BehaviorSubject<boolean | undefined>(undefined),
sort: new BehaviorSubject<OptionsListSortingType | undefined>(undefined),
selectedOptions: new BehaviorSubject<OptionsListSelection[] | undefined>(undefined),
searchTechnique: new BehaviorSubject<OptionsListSearchTechnique | undefined>(undefined),
},
displaySettings: {} as OptionsListDisplaySettings,
};
};

View file

@ -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;
}
}

View file

@ -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(
<OptionsListControlContext.Provider
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
}}
>
<OptionsListControl controlPanelClassName="controlPanel" />
</OptionsListControlContext.Provider>
);
};
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();
});
});

View file

@ -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<boolean>(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: (
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="xs">
<EuiFlexItem className="optionsList__selections" data-test-subj="optionsListSelections">
<div className="eui-textTruncate">
{excludeSelected && (
<>
<span className="optionsList__negateLabel">
{existsSelected
? OptionsListStrings.control.getExcludeExists()
: OptionsListStrings.control.getNegate()}
</span>{' '}
</>
)}
{existsSelected ? (
<span className={`optionsList__existsFilter`}>
{OptionsListStrings.controlAndPopover.getExists(+Boolean(excludeSelected))}
</span>
) : (
<>
{selectedOptions?.length
? selectedOptions.map((value: OptionsListSelection, i, { length }) => {
const text = `${fieldFormatter(value)}${
i + 1 === length ? '' : delimiter
} `;
const isInvalid = invalidSelections?.has(value);
return (
<span
key={value}
className={`optionsList__filter ${
isInvalid ? 'optionsList__filterInvalid' : ''
}`}
>
{text}
</span>
);
})
: null}
</>
)}
</div>
</EuiFlexItem>
{invalidSelections && invalidSelections.size > 0 && (
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.control.getInvalidSelectionWarningLabel(
invalidSelections.size
)}
delay="long"
>
<EuiToken
tabIndex={0}
iconType="alert"
size="s"
color="euiColorVis5"
shape="square"
fill="dark"
title={OptionsListStrings.control.getInvalidSelectionWarningLabel(
invalidSelections.size
)}
data-test-subj={`optionsList__invalidSelectionsToken-${api.uuid}`}
css={{ verticalAlign: 'text-bottom' }} // Align with the notification badge
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
};
}, [
selectedOptions,
excludeSelected,
existsSelected,
fieldFormatter,
delimiter,
invalidSelections,
api.uuid,
]);
const button = (
<>
<EuiFilterButton
badgeColor="success"
iconType={loading ? 'empty' : 'arrowDown'}
className={'optionsList--filterBtn'}
data-test-subj={`optionsList-control-${api.uuid}`}
onClick={() => 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()}
</EuiFilterButton>
</>
);
return (
<EuiFilterGroup fullWidth className={controlPanelClassName}>
<EuiInputPopover
id={popoverId}
ownFocus
input={button}
hasArrow={false}
repositionOnScroll
isOpen={isPopoverOpen}
panelPaddingSize="none"
panelMinWidth={MIN_POPOVER_WIDTH}
className="optionsList__inputButtonOverride"
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
closePopover={() => setPopoverOpen(false)}
panelClassName="optionsList__popoverOverride"
panelProps={{
title: panelTitle ?? defaultPanelTitle,
'aria-label': OptionsListStrings.popover.getAriaLabel(panelTitle ?? defaultPanelTitle!),
}}
>
<OptionsListPopover />
</EuiInputPopover>
</EuiFilterGroup>
);
};

View file

@ -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 = <State extends DefaultDataControlState = DefaultDataControlState>(
overwrite?: Partial<OptionsListControlState>
): State => {
return {
dataViewId: 'testDataViewId',
fieldName: 'fieldName',
...overwrite,
} as State;
};
const updateState = jest.fn();
const mountComponent = ({
initialState,
field,
parentApi = getMockedControlGroupApi(),
}: Pick<CustomOptionsComponentProps, 'initialState' | 'field'> & {
parentApi?: ControlGroupApi;
}) => {
const component = render(
<OptionsListEditorOptions
initialState={initialState}
field={field}
updateState={updateState}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
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<boolean>(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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'string' } as DataViewField}
updateState={updateState}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
/** 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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'ip' } as DataViewField} // initial search technique IS valid
updateState={jest.fn()}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'string' } as DataViewField}
updateState={updateState}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
/** 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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'number' } as DataViewField} // current selected search technique IS valid, initial state is not
updateState={jest.fn()}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'string' } as DataViewField}
updateState={updateState}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
/** 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(
<OptionsListEditorOptions
initialState={initialState}
field={{ type: 'number' } as DataViewField} // neither initial nor current search technique is valid
updateState={jest.fn()}
setControlEditorValid={jest.fn()}
parentApi={parentApi}
/>
);
expect(updateState).toBeCalledWith({ searchTechnique: 'exact' });
});
});
});
});

View file

@ -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: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.prefix.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.prefix.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__prefixSearchOptionAdditionalSetting',
},
{
id: 'wildcard',
label: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.wildcard.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.wildcard.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__wildcardSearchOptionAdditionalSetting',
},
{
id: 'exact',
label: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.exact.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.exact.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__exactSearchOptionAdditionalSetting',
},
];
export const OptionsListEditorOptions = ({
initialState,
field,
updateState,
parentApi,
}: CustomOptionsComponentProps<OptionsListControlState>) => {
const allowExpensiveQueries = useStateFromPublishingSubject(parentApi.allowExpensiveQueries$);
const [singleSelect, setSingleSelect] = useState<boolean>(initialState.singleSelect ?? false);
const [runPastTimeout, setRunPastTimeout] = useState<boolean>(
initialState.runPastTimeout ?? false
);
const [searchTechnique, setSearchTechnique] = useState<OptionsListSearchTechnique>(
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 (
<>
<EuiFormRow
label={OptionsListStrings.editor.getSelectionOptionsTitle()}
data-test-subj="optionsListControl__selectionOptionsRadioGroup"
>
<EuiRadioGroup
options={selectionOptions}
idSelected={singleSelect ? 'single' : 'multi'}
onChange={(id) => {
const newSingleSelect = id === 'single';
setSingleSelect(newSingleSelect);
updateState({ singleSelect: newSingleSelect });
}}
/>
</EuiFormRow>
{allowExpensiveQueries && compatibleSearchTechniques.length > 1 && (
<EuiFormRow
label={OptionsListStrings.editor.getSearchOptionsTitle()}
data-test-subj="optionsListControl__searchOptionsRadioGroup"
>
<EuiRadioGroup
options={searchOptions}
idSelected={searchTechnique}
onChange={(id) => {
const newSearchTechnique = id as OptionsListSearchTechnique;
setSearchTechnique(newSearchTechnique);
updateState({ searchTechnique: newSearchTechnique });
}}
/>
</EuiFormRow>
)}
<EuiFormRow label={OptionsListStrings.editor.getAdditionalSettingsTitle()}>
<EuiSwitch
label={
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
tooltip={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
/>
}
checked={runPastTimeout}
onChange={() => {
const newRunPastTimeout = !runPastTimeout;
setRunPastTimeout(newRunPastTimeout);
updateState({ runPastTimeout: newRunPastTimeout });
}}
data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'}
/>
</EuiFormRow>
</>
);
};

View file

@ -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(
<OptionsListControlContext.Provider
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
}}
>
<OptionsListPopover />
</OptionsListControlContext.Provider>
);
};
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<OptionsListDisplaySettings>;
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',
});
});
});
});

View file

@ -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 (
<div
id={`control-popover-${api.uuid}`}
className={'optionsList__popover'}
data-test-subj={`optionsList-control-popover`}
>
{field?.type !== 'boolean' && !displaySettings.hideActionBar && (
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected}
setShowOnlySelected={setShowOnlySelected}
/>
)}
<div
data-test-subj={`optionsList-control-available-options`}
data-option-count={loading ? 0 : Object.keys(availableOptions ?? {}).length}
style={{ width: '100%', height: '100%' }}
>
<OptionsListPopoverSuggestions showOnlySelected={showOnlySelected} />
{!showOnlySelected && invalidSelections && invalidSelections.size !== 0 && (
<OptionsListPopoverInvalidSelections />
)}
</div>
{!displaySettings.hideExclude && <OptionsListPopoverFooter />}
</div>
);
};

View file

@ -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 (
<div className="optionsList__actions">
{compatibleSearchTechniques.length > 0 && (
<EuiFormRow className="optionsList__searchRow" fullWidth>
<EuiFieldSearch
isInvalid={!searchStringValid}
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => {
stateManager.searchString.next(event.target.value);
}}
value={searchString}
data-test-subj="optionsList-control-search-input"
placeholder={OptionsListStrings.popover.getSearchPlaceholder(
allowExpensiveQueries ? defaultSearchTechnique : 'exact'
)}
/>
</EuiFormRow>
)}
<EuiFormRow className="optionsList__actionsRow" fullWidth>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="s"
responsive={false}
>
{allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" data-test-subj="optionsList-cardinality-label">
{OptionsListStrings.popover.getCardinalityLabel(totalCardinality)}
</EuiText>
</EuiFlexItem>
)}
{invalidSelections && invalidSelections.size > 0 && (
<>
{allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<div className="optionsList__actionBarDivider" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.size)}
</EuiText>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={true}>
<EuiFlexGroup
gutterSize="xs"
alignItems="center"
justifyContent="flexEnd"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="xs"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
onClick={() => setShowOnlySelected(!showOnlySelected)}
data-test-subj="optionsList-control-show-only-selected"
aria-label={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
/>
</EuiToolTip>
</EuiFlexItem>
{!displaySettings.hideSort && (
<EuiFlexItem grow={false}>
<OptionsListPopoverSortingButton showOnlySelected={showOnlySelected} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
);
};

View file

@ -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 (
<EuiSelectableMessage
tabIndex={0}
data-test-subj={`optionsList-control-${
showOnlySelected ? 'selectionsEmptyMessage' : 'noSelectionsMessage'
}`}
>
<EuiIcon
type={searchStringValid ? 'minusInCircle' : 'alert'}
color={searchStringValid ? 'default' : 'danger'}
/>
<EuiSpacer size="xs" />
{noResultsMessage}
</EuiSelectableMessage>
);
};

View file

@ -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 (
<>
<EuiPopoverFooter
paddingSize="none"
css={css`
background-color: ${useEuiBackgroundColor('subdued')};
`}
>
{loading && (
<div style={{ position: 'absolute', width: '100%' }}>
<EuiProgress
data-test-subj="optionsList-control-popover-loading"
size="xs"
color="accent"
/>
</div>
)}
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
css={css`
padding: ${useEuiPaddingSize('s')};
`}
justifyContent={'spaceBetween'}
>
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend={OptionsListStrings.popover.getIncludeExcludeLegend()}
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) =>
stateManager.exclude.next(optionId === 'optionsList__excludeResults')
}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"
/>
</EuiFlexItem>
{!allowExpensiveQueries && (
<EuiFlexItem data-test-subj="optionsList-allow-expensive-queries-warning" grow={false}>
<EuiIconTip
type="warning"
color="warning"
content={OptionsListStrings.popover.getAllowExpensiveQueriesWarning()}
aria-label={OptionsListStrings.popover.getAllowExpensiveQueriesWarning()}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPopoverFooter>
</>
);
};

View file

@ -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<EuiSelectableOption[]>([]); // 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: (
<EuiScreenReaderOnly>
<div>
{OptionsListStrings.popover.getInvalidSelectionScreenReaderText()}
{'" "'} {/* Adds a pause for the screen reader */}
</div>
</EuiScreenReaderOnly>
),
};
});
setSelectableOptions(options);
}, [fieldFormatter, invalidSelections]);
return (
<>
<EuiSpacer size="s" />
<EuiTitle
size="xxs"
className="optionsList-control-ignored-selection-title"
data-test-subj="optionList__invalidSelectionLabel"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon
type="warning"
color="warning"
title={OptionsListStrings.popover.getInvalidSelectionScreenReaderText()}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem>
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(invalidSelections.size)}
</label>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
<EuiSelectable
aria-label={OptionsListStrings.popover.getInvalidSelectionsSectionAriaLabel(
defaultPanelTitle ?? '',
invalidSelections.size
)}
options={selectableOptions}
listProps={{ onFocusBadge: false, isVirtualized: false }}
onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions);
api.deselectOption(changedOption.key);
}}
>
{(list) => list}
</EuiSelectable>
</>
);
};

View file

@ -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(
<OptionsListControlContext.Provider
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
}}
>
<OptionsListPopoverSortingButton showOnlySelected={false} />
</OptionsListControlContext.Provider>
);
// 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']);
});
});

View file

@ -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<SortByItem[]>(() => {
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 = () => (
<EuiButtonIcon
size="xs"
display="empty"
color="text"
iconType={selectedSort.direction === 'asc' ? 'sortAscending' : 'sortDescending'}
isDisabled={showOnlySelected}
className="optionsList__sortButton"
data-test-subj="optionsListControl__sortingOptionsButton"
onClick={() => setIsSortingPopoverOpen(!isSortingPopoverOpen)}
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
/>
);
return (
<EuiPopover
button={
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getSortDisabledTooltip()
: OptionsListStrings.popover.getSortPopoverTitle()
}
>
<SortButton />
</EuiToolTip>
}
panelPaddingSize="none"
isOpen={isSortingPopoverOpen}
aria-labelledby="optionsList_sortingOptions"
closePopover={() => setIsSortingPopoverOpen(false)}
panelClassName={'optionsList--sortPopover'}
>
<span data-test-subj="optionsListControl__sortingOptionsPopover">
<EuiPopoverTitle paddingSize="s">
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem>{OptionsListStrings.popover.getSortPopoverTitle()}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
isIconOnly
buttonSize="compressed"
options={sortOrderOptions}
idSelected={selectedSort.direction ?? OPTIONS_LIST_DEFAULT_SORT.direction}
legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()}
onChange={(value) => {
stateManager.sort.next({
...selectedSort,
direction: value as Direction,
});
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<EuiSelectable
options={sortByOptions}
singleSelection="always"
onChange={onSortByChange}
id="optionsList_sortingOptions"
listProps={{ bordered: false }}
data-test-subj="optionsListControl__sortingOptions"
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
>
{(list) => list}
</EuiSelectable>
</span>
</EuiPopover>
);
};

View file

@ -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 (
<>
<EuiToolTip
content={OptionsListStrings.popover.getDocumentCountTooltip(documentCount)}
position={'right'}
>
<EuiText
size="xs"
aria-hidden={true}
className="eui-textNumber"
color={euiTheme.colors.subduedText}
data-test-subj="optionsList-document-count-badge"
css={css`
font-weight: ${euiTheme.font.weight.medium} !important;
`}
>
{`${documentCount.toLocaleString()}`}
</EuiText>
</EuiToolTip>
<EuiScreenReaderOnly>
<div>
{'" "'} {/* Adds a pause for the screen reader */}
{OptionsListStrings.popover.getDocumentCountScreenReaderText(documentCount)}
</div>
</EuiScreenReaderOnly>
</>
);
};

View file

@ -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<HTMLDivElement>(null);
const canLoadMoreSuggestions = useMemo<boolean>(
() =>
allowExpensiveQueries && totalCardinality && !showOnlySelected // && searchString.valid
? (availableOptions ?? []).length <
Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE)
: false,
[availableOptions, totalCardinality, showOnlySelected, allowExpensiveQueries]
);
const suggestions = useMemo<OptionsListSuggestions | OptionsListSelection[]>(() => {
return (showOnlySelected ? selectedOptions : availableOptions) ?? [];
}, [availableOptions, selectedOptions, showOnlySelected]);
const existsSelectableOption = useMemo<EuiSelectableOption | undefined>(() => {
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<EuiSelectableOption[]>([]); // 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 ? (
<OptionsListPopoverSuggestionBadge documentCount={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 (
<EuiHighlight search={option.key === 'exists-option' ? '' : searchStringValue}>
{option.label}
</EuiHighlight>
);
},
[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 (
<>
<div ref={listRef}>
<EuiSelectable
options={selectableOptions}
renderOption={(option) => renderOption(option, searchString)}
listProps={{ onFocusBadge: false }}
aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel(
fieldName,
selectableOptions.length
)}
emptyMessage={<OptionsListPopoverEmptyMessage showOnlySelected={showOnlySelected} />}
onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions);
api.makeSelection(changedOption.key, showOnlySelected);
}}
>
{(list) => list}
</EuiSelectable>
</div>
</>
);
};

View file

@ -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;

View file

@ -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<OptionsListControlApi, 'dataViews' | 'field$' | 'setBlockingError' | 'parentApi'> &
Pick<OptionsListComponentApi, 'loadMoreSubject'> & {
controlFetch$: Observable<ControlFetchContext>;
loadingSuggestions$: BehaviorSubject<boolean>;
debouncedSearchString: Observable<string>;
};
services: DataControlServices;
stateManager: ControlStateManager<OptionsListComponentState>;
}): Observable<OptionsListSuccessResponse | { error: Error }> {
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);
})
);
}

View file

@ -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<DataView> => {
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(<Component className={'controlPanel'} />);
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(<Component className={'controlPanel'} />);
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(<Component className={'controlPanel'} />);
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(<Component className={'controlPanel'} />);
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',
},
},
},
]);
});
});
});

View file

@ -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<OptionsListControlState, OptionsListControlApi> => {
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<OptionsListSearchTechnique | undefined>(
initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE
);
const runPastTimeout$ = new BehaviorSubject<boolean | undefined>(initialState.runPastTimeout);
const singleSelect$ = new BehaviorSubject<boolean | undefined>(initialState.singleSelect);
const selections$ = new BehaviorSubject<OptionsListSelection[] | undefined>(
initialState.selectedOptions ?? []
);
const sort$ = new BehaviorSubject<OptionsListSortingType | undefined>(
initialState.sort ?? OPTIONS_LIST_DEFAULT_SORT
);
const existsSelected$ = new BehaviorSubject<boolean | undefined>(initialState.existsSelected);
const excludeSelected$ = new BehaviorSubject<boolean | undefined>(initialState.exclude);
/** Creation options state - cannot currently be changed after creation, but need subjects for comparators */
const placeholder$ = new BehaviorSubject<string | undefined>(initialState.placeholder);
const hideActionBar$ = new BehaviorSubject<boolean | undefined>(initialState.hideActionBar);
const hideExclude$ = new BehaviorSubject<boolean | undefined>(initialState.hideExclude);
const hideExists$ = new BehaviorSubject<boolean | undefined>(initialState.hideExists);
const hideSort$ = new BehaviorSubject<boolean | undefined>(initialState.hideSort);
/** Runtime / component state - none of this is serialized */
const searchString$ = new BehaviorSubject<string>('');
const searchStringValid$ = new BehaviorSubject<boolean>(true);
const requestSize$ = new BehaviorSubject<number>(MIN_OPTIONS_LIST_REQUEST_SIZE);
const availableOptions$ = new BehaviorSubject<OptionsListSuggestions | undefined>(undefined);
const invalidSelections$ = new BehaviorSubject<Set<OptionsListSelection>>(new Set());
const totalCardinality$ = new BehaviorSubject<number>(0);
const dataControl = initializeDataControl<
Pick<OptionsListControlState, 'searchTechnique' | 'singleSelect'>
>(
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<boolean>(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>(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 (
<OptionsListControlContext.Provider
value={{
stateManager,
api: componentApi,
displaySettings: { placeholder, hideActionBar, hideExclude, hideExists, hideSort },
}}
>
<OptionsListControl controlPanelClassName={controlPanelClassName} />
</OptionsListControlContext.Provider>
);
},
};
},
};
};

View file

@ -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<OptionsListComponentState>;
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;
};

View file

@ -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<string, OptionsListSuccessResponse>;
constructor() {
this.cache = new LRUCache<string, OptionsListSuccessResponse>({
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<OptionsListResponse> {
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<OptionsListResponse>(
`/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;
}
}
}

View file

@ -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',
}),
},
},
},
};

View file

@ -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<OptionsListControlState, keyof OptionsListDisplaySettings> {
searchString: string;
searchStringValid: boolean;
requestSize: number;
}
interface PublishesOptions {
availableOptions$: PublishingSubject<OptionsListSuggestions | undefined>;
invalidSelections$: PublishingSubject<Set<OptionsListSelection>>;
totalCardinality$: PublishingSubject<number>;
}
export type OptionsListComponentApi = OptionsListControlApi &
PublishesOptions & {
deselectOption: (key: string | undefined) => void;
makeSelection: (key: string | undefined, showOnlySelected: boolean) => void;
loadMoreSubject: BehaviorSubject<null>;
};

View file

@ -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<TimeRange | undefined>(undefined),
};
const controlGroupApi = {
controlFetch$: () => new BehaviorSubject<ControlFetchContext>({}),
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<DataView> => {
mockDataViews.get = jest.fn().mockImplementation(async (id: string): Promise<DataView> => {
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<RangesliderControlApi>,
nextComparitors: StateComparators<RangesliderControlState>
) {
return {
...api,
uuid,
parentApi: controlGroupApi,
unsavedChanges: new BehaviorSubject<Partial<RangesliderControlState> | 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(
<CustomSettings
currentState={{}}
initialState={{} as RangesliderControlState}
field={{} as DataViewField}
updateState={jest.fn()}
setControlEditorValid={jest.fn()}
parentApi={controlGroupApi}
/>
);
expect(
@ -274,9 +256,11 @@ describe('RangesliderControlApi', () => {
const CustomSettings = factory.CustomOptionsComponent!;
const component = render(
<CustomSettings
currentState={{}}
initialState={{} as RangesliderControlState}
field={{} as DataViewField}
updateState={jest.fn()}
setControlEditorValid={setControlEditorValid}
parentApi={controlGroupApi}
/>
);
@ -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'), {

View file

@ -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<RangesliderControlState, RangesliderControlApi> => {
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 (
<>
<EuiFormRow fullWidth label={RangeSliderStrings.editor.getStepTitle()}>
@ -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 (
<RangeSliderControl
controlPanelClassName={controlPanelClassName}

View file

@ -6,9 +6,6 @@
* Side Public License, v 1.
*/
import { CoreStart } from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DataControlApi, DefaultDataControlState } from '../types';
export const RANGE_SLIDER_CONTROL_TYPE = 'rangeSlider';
@ -21,9 +18,3 @@ export interface RangesliderControlState extends DefaultDataControlState {
}
export type RangesliderControlApi = DataControlApi;
export interface Services {
core: CoreStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
}

View file

@ -6,20 +6,18 @@
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, skip } from 'rxjs';
import { EuiFieldSearch, EuiFormRow, EuiRadioGroup } from '@elastic/eui';
import { css } from '@emotion/react';
import { CoreStart } from '@kbn/core/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { euiThemeVars } from '@kbn/ui-theme';
import { initializeDataControl } from '../initialize_data_control';
import { DataControlFactory } from '../types';
import { DataControlFactory, DataControlServices } from '../types';
import {
SearchControlApi,
SearchControlState,
@ -46,13 +44,9 @@ const allSearchOptions = [
const DEFAULT_SEARCH_TECHNIQUE = 'match';
export const getSearchControlFactory = ({
core,
dataViewsService,
}: {
core: CoreStart;
dataViewsService: DataViewsPublicPluginStart;
}): DataControlFactory<SearchControlState, SearchControlApi> => {
export const getSearchControlFactory = (
services: DataControlServices
): DataControlFactory<SearchControlState, SearchControlApi> => {
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 (
<EuiFormRow label={'Searching'} data-test-subj="searchControl__searchOptionsRadioGroup">
<EuiRadioGroup
@ -74,6 +71,7 @@ export const getSearchControlFactory = ({
idSelected={searchTechnique}
onChange={(id) => {
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(

View file

@ -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<DataViewField | undefined>;
fieldFormatter: PublishingSubject<DataControlFieldFormatter>;
}
export type DataControlApi = DefaultControlApi &
Omit<PublishesPanelTitle, 'hidePanelTitle'> & // 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<State, 'fieldName'>;
field: DataViewField;
updateState: (newState: Partial<State>) => void;
setControlEditorValid: (valid: boolean) => void;
parentApi: ControlGroupApi;
}
export interface DataControlFactory<
State extends DefaultDataControlState = DefaultDataControlState,
Api extends DataControlApi = DataControlApi
> extends ControlFactory<State, Api> {
isFieldCompatible: (field: DataViewField) => boolean;
CustomOptionsComponent?: React.FC<{
currentState: Partial<State>;
updateState: (newState: Partial<State>) => void;
setControlEditorValid: (valid: boolean) => void;
}>;
CustomOptionsComponent?: React.FC<CustomOptionsComponentProps<State>>;
}
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;
}

View file

@ -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<PublishesUnifiedSearch> = {
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
},
overwriteApi?: Partial<ControlGroupApi>
) => {
return {
parentApi: dashboardApi,
autoApplySelections$: new BehaviorSubject(true),
ignoreParentSettings$: new BehaviorSubject(undefined),
controlFetch$: () => new BehaviorSubject<ControlFetchContext>({}),
allowExpensiveQueries$: new BehaviorSubject(true),
...overwriteApi,
} as unknown as ControlGroupApi;
};
export const getMockedBuildApi =
<StateType extends object = object, ApiType extends DefaultControlApi = DefaultControlApi>(
uuid: string,
factory: ControlFactory<StateType, ApiType>,
controlGroupApi?: ControlGroupApi
) =>
(api: ControlApiRegistration<ApiType>, nextComparators: StateComparators<StateType>) => {
return {
...api,
uuid,
parentApi: controlGroupApi ?? getMockedControlGroupApi(),
unsavedChanges: new BehaviorSubject<Partial<StateType> | undefined>(undefined),
resetUnsavedChanges: () => {},
type: factory.type,
};
};

View file

@ -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<TimeRange | undefined>(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();

View file

@ -36,5 +36,6 @@
"@kbn/datemath",
"@kbn/ui-theme",
"@kbn/react-kibana-context-render",
"@kbn/field-formats-plugin",
]
}

View file

@ -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;
};
/**