mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
5c9853dc57
commit
a1fe300a03
49 changed files with 4464 additions and 134 deletions
129
examples/controls_example/common/options_list/ip_search.test.ts
Normal file
129
examples/controls_example/common/options_list/ip_search.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
122
examples/controls_example/common/options_list/ip_search.ts
Normal file
122
examples/controls_example/common/options_list/ip_search.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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: () => {
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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!);
|
||||
}}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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>;
|
||||
};
|
|
@ -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'), {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -36,5 +36,6 @@
|
|||
"@kbn/datemath",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/field-formats-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue