[Controls] Allow wildcard searching in options list (#158427)

Closes https://github.com/elastic/kibana/issues/157157
Closes https://github.com/elastic/kibana/issues/152921 

## Summary

The primary goal of this PR is to introduce an option for wildcard
("contains") searching to the options list control:



6847d0c5-014a-4322-8e59-308bc3ca27aa

### New Flyout Design

However, since this required adding a radio group to the custom options
list settings in the create/edit control flyout, I also made some
changes to the flyout design in order to better accommodate this (we
were previously using `EuiSwitch` components for the
control-type-specific settings, which did not work in this case because
I wanted to be able to add a tooltip to describe each search type):

| Before | After |
|--------|-------|
| ![May-29-2023
09-04-02](e7a8dba8-0815-4460-b55a-f622300e40ac)
| ![May-29-2023
09-00-21](50fa6795-f7e1-4329-bac6-045f6f8464b3)
|

Note in the above GIFs that, since I was using an `EuiRadioGroup` for
the search technique setting, I decided it made more sense + was more
consistent for the "Allow multiple selections in dropdown" to also be
converted to a radio group rather than a switch. The "Ignore timeout for
results" setting is the only one that remained a switch in the new
design:

| Before | After |
|--------|-------|
|
![image](53b1168c-b172-4cd0-8fbf-ce80a03964c0)
|
![image](60b174a6-635f-4b09-8f1a-fef1b0e7bb2c)
|


### `EuiSwitch` with Tooltip Bug

As part of this redesign, I also fixed a very quick bug where, because
the old `SwitchWithTooltip` was defined **inside** the larger
`OptionsListEditorOptions` component, any state update on
`OptionsListEditorOptions` would cause `SwitchWithTooltip` to **also**
be re-rendered - this caused the "slide" animation to be interrupted on
click:

| Before | After |
|--------|-------|
|
![image](03bebc7c-b529-463e-a042-7aa680c99eeb)
|
![image](db0fe6e7-d352-476a-8b77-d93d835c9942)
|




### Title Bug Fix
And, since I was making so many changes to the flyout code as part of
refactoring the code (including the design changes above), I also fixed
a bug with control titles where things weren't getting set properly. To
test this, consider taking the following steps:
1. Create a new options list control, keeping the default title
2. Edit that options list control and change it to a range slider
control by selecting a number field
3. Notice that...
     - Before this PR, the title gets completely cleared:

![image](cdd9ecc2-c729-402c-8f69-018de3e60342)<br>
- After this PR, the default title gets updated to the range slider
field name:

![image](d56ec9f9-6961-4799-ba3b-623ae5f46d91)<br>
4. Delete that range slider control and create a new control, keeping
the default title once again (options list or range slider, the type
doesn't matter).
5. Edit the control and change the field to a different field **of the
same type** (i.e. if your control from step 4 was an options list
control, select a field that keeps it as an options list control).
Before saving your changes, notice that the "default title" in the
`Label` input gets updated to the new field title:<br>
<img width="450"
src="0fabe2e3-7f83-4f2a-87e6-33253652972d"/><br>
6. After saving, notice that...
- Before this PR, the title doesn't actually get updated to the new
default title:

![image](b9262c67-9841-47f0-8d25-d5689fe408de)<br>
     - After this PR, the title gets updated as expected:

![image](1a2b3f19-a02c-4525-9d2c-8029c020a117)<br>

### Flaky Test Runner


[test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2343):
<img
src="f2ed9d65-adcf-47af-bb00-ee11837c406b"/>

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2023-06-06 08:32:10 -06:00 committed by GitHub
parent 67e60c0c1c
commit 8277665dbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1126 additions and 442 deletions

View file

@ -40,6 +40,7 @@ export const ControlPanelDiffSystems: {
hideExclude: hideExcludeA,
selectedOptions: selectedA,
singleSelect: singleSelectA,
searchTechnique: searchTechniqueA,
existsSelected: existsSelectedA,
runPastTimeout: runPastTimeoutA,
...inputA
@ -52,6 +53,7 @@ export const ControlPanelDiffSystems: {
hideExclude: hideExcludeB,
selectedOptions: selectedB,
singleSelect: singleSelectB,
searchTechnique: searchTechniqueB,
existsSelected: existsSelectedB,
runPastTimeout: runPastTimeoutB,
...inputB
@ -65,6 +67,7 @@ export const ControlPanelDiffSystems: {
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
isEqual(searchTechniqueA ?? 'prefix', searchTechniqueB ?? 'prefix') &&
deepEqual(sortA ?? OPTIONS_LIST_DEFAULT_SORT, sortB ?? OPTIONS_LIST_DEFAULT_SORT) &&
isEqual(selectedA ?? [], selectedB ?? []) &&
deepEqual(inputA, inputB)

View file

@ -6,8 +6,10 @@
* Side Public License, v 1.
*/
import { getDefaultControlGroupInput } from '..';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { ControlGroupInput } from './types';
import { getDefaultControlGroupInput } from '..';
import { ControlGroupContainerFactory } from '../../public';
export const mockControlGroupInput = (partial?: Partial<ControlGroupInput>): ControlGroupInput => ({
id: 'mocked_control_group',
@ -45,3 +47,16 @@ export const mockControlGroupInput = (partial?: Partial<ControlGroupInput>): Con
},
...(partial ?? {}),
});
export const mockControlGroupContainer = async (explicitInput?: Partial<ControlGroupInput>) => {
const controlGroupFactoryStub = new ControlGroupContainerFactory(
{} as unknown as EmbeddablePersistableStateService
);
const controlGroupContainer = await controlGroupFactoryStub.create({
id: 'mocked-control-group',
...getDefaultControlGroupInput(),
...explicitInput,
});
return controlGroupContainer;
};

View file

@ -7,7 +7,7 @@
*/
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public';
import { OptionsListComponentState, OptionsListReduxState } from '../../public/options_list/types';
import { OptionsListComponentState } from '../../public/options_list/types';
import { ControlFactory, ControlOutput } from '../../public/types';
import { OptionsListEmbeddableInput } from './types';
@ -44,7 +44,10 @@ const mockOptionsListOutput = {
loading: false,
} as ControlOutput;
export const mockOptionsListEmbeddable = async (partialState?: Partial<OptionsListReduxState>) => {
export const mockOptionsListEmbeddable = async (partialState?: {
explicitInput?: Partial<OptionsListEmbeddableInput>;
componentState?: Partial<OptionsListComponentState>;
}) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();
const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});

View file

@ -14,7 +14,11 @@ import type { DataControlInput } from '../types';
export const OPTIONS_LIST_CONTROL = 'optionsListControl';
export type OptionsListSearchTechnique = 'prefix' | 'wildcard';
export const OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE: OptionsListSearchTechnique = 'prefix';
export interface OptionsListEmbeddableInput extends DataControlInput {
searchTechnique?: OptionsListSearchTechnique;
sort?: OptionsListSortingType;
selectedOptions?: string[];
existsSelected?: boolean;
@ -23,9 +27,9 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
hideActionBar?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
placeholder?: string;
hideSort?: boolean;
exclude?: boolean;
placeholder?: string;
}
export type OptionsListSuggestions = Array<{ value: string; docCount?: number }>;
@ -61,6 +65,7 @@ export type OptionsListRequest = Omit<
OptionsListRequestBody,
'filters' | 'fieldName' | 'fieldSpec' | 'textFieldName'
> & {
searchTechnique?: OptionsListSearchTechnique;
allowExpensiveQueries: boolean;
timeRange?: TimeRange;
runPastTimeout?: boolean;
@ -75,6 +80,7 @@ export type OptionsListRequest = Omit<
*/
export interface OptionsListRequestBody {
runtimeFieldMap?: Record<string, RuntimeFieldSpec>;
searchTechnique?: OptionsListSearchTechnique;
allowExpensiveQueries: boolean;
sort?: OptionsListSortingType;
filters?: Array<{ bool: BoolQuery }>;

View file

@ -115,8 +115,6 @@ export class EditControlAction implements Action<EditControlActionContext> {
flyout.close();
},
ownFocus: true,
// @ts-ignore - TODO: Remove this once https://github.com/elastic/eui/pull/6645 lands in Kibana
focusTrapProps: { scrollLock: true },
}
);
setFlyoutRef(flyoutInstance);

View file

@ -7,11 +7,16 @@
*/
import { isEqual } from 'lodash';
import React, { useState } from 'react';
import React from 'react';
import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public';
import { DataControlInput, ControlEmbeddable, IEditableControlFactory } from '../../types';
import {
DataControlInput,
ControlEmbeddable,
IEditableControlFactory,
DataControlEditorChanges,
} from '../../types';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { useControlGroupContainer } from '../embeddable/control_group_container';
@ -38,18 +43,14 @@ export const EditControlFlyout = ({
const panels = controlGroup.select((state) => state.explicitInput.panels);
const panel = panels[embeddable.id];
const [currentGrow, setCurrentGrow] = useState(panel.grow);
const [currentWidth, setCurrentWidth] = useState(panel.width);
const [inputToReturn, setInputToReturn] = useState<Partial<DataControlInput>>({});
const onCancel = () => {
const onCancel = (changes: DataControlEditorChanges) => {
if (
isEqual(panel.explicitInput, {
...panel.explicitInput,
...inputToReturn,
...changes.input,
}) &&
currentGrow === panel.grow &&
currentWidth === panel.width
changes.grow === panel.grow &&
changes.width === panel.width
) {
closeFlyout();
return;
@ -66,22 +67,29 @@ export const EditControlFlyout = ({
});
};
const onSave = async (type?: string) => {
const onSave = async (changes: DataControlEditorChanges, type?: string) => {
if (!type) {
closeFlyout();
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (!factory) throw new EmbeddableFactoryNotFoundError(type);
let inputToReturn = changes.input;
if (factory.presaveTransformFunction) {
setInputToReturn(factory.presaveTransformFunction(inputToReturn, embeddable));
inputToReturn = factory.presaveTransformFunction(inputToReturn, embeddable);
}
if (currentWidth !== panel.width)
controlGroup.dispatch.setControlWidth({ width: currentWidth, embeddableId: embeddable.id });
if (currentGrow !== panel.grow)
controlGroup.dispatch.setControlGrow({ grow: currentGrow, embeddableId: embeddable.id });
if (changes.width && changes.width !== panel.width)
controlGroup.dispatch.setControlWidth({
width: changes.width,
embeddableId: embeddable.id,
});
if (changes.grow !== undefined && changes.grow !== panel.grow) {
controlGroup.dispatch.setControlGrow({
grow: changes.grow,
embeddableId: embeddable.id,
});
}
closeFlyout();
await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type);
@ -93,16 +101,9 @@ export const EditControlFlyout = ({
width={panel.width}
grow={panel.grow}
embeddable={embeddable}
title={embeddable.getTitle()}
onCancel={() => onCancel()}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
onCancel={onCancel}
setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)}
updateWidth={(newWidth) => setCurrentWidth(newWidth)}
updateGrow={(newGrow) => setCurrentGrow(newGrow)}
onTypeEditorChange={(partialInput) => {
setInputToReturn({ ...inputToReturn, ...partialInput });
}}
onSave={(type) => onSave(type)}
onSave={onSave}
removeControl={() => {
closeFlyout();
removeControl();

View file

@ -41,30 +41,73 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', {
defaultMessage: 'Edit control',
}),
getDataViewTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataViewTitle', {
defaultMessage: 'Data view',
}),
getFieldTitle: () =>
i18n.translate('controls.controlGroup.manageControl.fielditle', {
defaultMessage: 'Field',
}),
getTitleInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.titleInputTitle', {
defaultMessage: 'Label',
}),
getControlTypeTitle: () =>
i18n.translate('controls.controlGroup.manageControl.controlTypesTitle', {
defaultMessage: 'Control type',
}),
getWidthInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.widthInputTitle', {
defaultMessage: 'Minimum width',
}),
getControlSettingsTitle: () =>
i18n.translate('controls.controlGroup.manageControl.controlSettingsTitle', {
defaultMessage: 'Additional settings',
}),
dataSource: {
getFormGroupTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupTitle', {
defaultMessage: 'Data source',
}),
getFormGroupDescription: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupDescription', {
defaultMessage: 'Select the data view and field that you want to create a control for.',
}),
getSelectDataViewMessage: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.selectDataViewMessage', {
defaultMessage: 'Please select a data view',
}),
getDataViewTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.dataViewTitle', {
defaultMessage: 'Data view',
}),
noControlTypeMessage: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.noControlTypeMessage', {
defaultMessage: 'No field selected yet',
}),
getFieldTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.fieldTitle', {
defaultMessage: 'Field',
}),
getControlTypeTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.controlTypesTitle', {
defaultMessage: 'Control type',
}),
},
displaySettings: {
getFormGroupTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupTitle', {
defaultMessage: 'Display settings',
}),
getFormGroupDescription: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupDescription', {
defaultMessage: 'Change how the control appears on your dashboard.',
}),
getTitleInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.titleInputTitle', {
defaultMessage: 'Label',
}),
getWidthInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.widthInputTitle', {
defaultMessage: 'Minimum width',
}),
getGrowSwitchTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.growSwitchTitle', {
defaultMessage: 'Expand width to fit available space',
}),
},
controlTypeSettings: {
getFormGroupTitle: (type: string) =>
i18n.translate('controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle', {
defaultMessage: '{controlType} settings',
values: { controlType: type },
}),
getFormGroupDescription: (type: string) =>
i18n.translate(
'controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription',
{
defaultMessage: 'Custom settings for your {controlType} control.',
values: { controlType: type.toLocaleLowerCase() },
}
),
},
getSaveChangesTitle: () =>
i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', {
defaultMessage: 'Save and close',
@ -73,18 +116,6 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.manageControl.cancelTitle', {
defaultMessage: 'Cancel',
}),
getSelectFieldMessage: () =>
i18n.translate('controls.controlGroup.manageControl.selectFieldMessage', {
defaultMessage: 'Please select a field',
}),
getSelectDataViewMessage: () =>
i18n.translate('controls.controlGroup.manageControl.selectDataViewMessage', {
defaultMessage: 'Please select a data view',
}),
getGrowSwitchTitle: () =>
i18n.translate('controls.controlGroup.manageControl.growSwitchTitle', {
defaultMessage: 'Expand width to fit available space',
}),
},
management: {
getAddControlTitle: () =>

View file

@ -0,0 +1,251 @@
/*
* 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 { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { ControlEditor, EditControlProps } from './control_editor';
import { OptionsListEmbeddableFactory } from '../..';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
import { mockControlGroupContainer, mockOptionsListEmbeddable } from '../../../common/mocks';
import { RangeSliderEmbeddableFactory } from '../../range_slider';
import { ControlGroupContainerContext } from '../embeddable/control_group_container';
import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
} from '../../../common';
describe('Data control editor', () => {
interface MountOptions {
componentOptions?: Partial<EditControlProps>;
explicitInput?: Partial<ControlGroupInput>;
}
pluginServices.getServices().dataViews.get = jest.fn().mockResolvedValue(stubDataView);
pluginServices.getServices().dataViews.getIdsWithTitle = jest
.fn()
.mockResolvedValue([{ id: stubDataView.id, title: stubDataView.getIndexPattern() }]);
pluginServices.getServices().controls.getControlTypes = jest
.fn()
.mockReturnValue([OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL]);
pluginServices.getServices().controls.getControlFactory = jest
.fn()
.mockImplementation((type: string) => {
if (type === OPTIONS_LIST_CONTROL) return new OptionsListEmbeddableFactory();
if (type === RANGE_SLIDER_CONTROL) return new RangeSliderEmbeddableFactory();
});
let controlEditor: ReactWrapper = new ReactWrapper(<></>);
async function mountComponent(options?: MountOptions) {
const controlGroupContainer = await mockControlGroupContainer(options?.explicitInput);
await act(async () => {
controlEditor = mountWithIntl(
<ControlGroupContainerContext.Provider value={controlGroupContainer}>
<ControlEditor
setLastUsedDataViewId={jest.fn()}
getRelevantDataViewId={() => stubDataView.id}
isCreate={true}
width={DEFAULT_CONTROL_WIDTH}
grow={DEFAULT_CONTROL_GROW}
onSave={jest.fn()}
onCancel={jest.fn()}
{...options?.componentOptions}
/>
</ControlGroupContainerContext.Provider>
);
});
await new Promise(process.nextTick);
controlEditor.update();
}
const selectField = async (fieldName: string) => {
const option = findTestSubject(controlEditor, `field-picker-select-${fieldName}`);
await act(async () => {
option.simulate('click');
});
controlEditor.update();
};
describe('creating a new control', () => {
test('does not show non-aggregatable field', async () => {
await mountComponent();
const nonAggOption = findTestSubject(controlEditor, 'field-picker-select-machine.os');
expect(nonAggOption.exists()).toBe(false);
});
describe('selecting a keyword field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('machine.os.raw');
});
test('creates an options list control', async () => {
expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual(
'Options list'
);
});
test('has custom settings', async () => {
const searchOptions = findTestSubject(controlEditor, 'control-editor-custom-settings');
expect(searchOptions.exists()).toBe(true);
});
test('has custom search options', async () => {
const searchOptions = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(searchOptions.exists()).toBe(true);
});
});
describe('selecting an IP field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('clientip');
});
test('creates an options list control', async () => {
expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual(
'Options list'
);
});
test('does not have custom search options', async () => {
const searchOptions = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(searchOptions.exists()).toBe(false);
});
});
describe('selecting a number field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('bytes');
});
test('creates a range slider control', async () => {
expect(findTestSubject(controlEditor, 'control-editor-type').text()).toEqual(
'Range slider'
);
});
test('does not have any custom settings', async () => {
const searchOptions = findTestSubject(controlEditor, 'control-editor-custom-settings');
expect(searchOptions.exists()).toBe(false);
});
});
test('selects given width and grow', async () => {
await mountComponent({ componentOptions: { grow: false, width: 'small' } });
const selectedClass = 'euiButtonGroupButton-isSelected';
expect(
findTestSubject(controlEditor, 'control-editor-width-medium').hasClass(selectedClass)
).toBe(false);
expect(
findTestSubject(controlEditor, 'control-editor-width-small').hasClass(selectedClass)
).toBe(true);
expect(
findTestSubject(controlEditor, 'control-editor-width-large').hasClass(selectedClass)
).toBe(false);
expect(
findTestSubject(controlEditor, 'control-editor-grow-switch').prop('aria-checked')
).toBe(false);
});
});
describe('editing existing options list control', () => {
const openOptionsListEditor = async (explicitInput?: Partial<OptionsListEmbeddableInput>) => {
const control = await mockOptionsListEmbeddable({
explicitInput: {
title: 'machine.os.raw',
dataViewId: stubDataView.id,
fieldName: 'machine.os.raw',
...explicitInput,
},
});
await mountComponent({
componentOptions: { isCreate: false, embeddable: control },
});
};
describe('control title', () => {
test('auto-fills default', async () => {
await openOptionsListEditor();
const titleInput = findTestSubject(controlEditor, 'control-editor-title-input');
expect(titleInput.prop('value')).toBe('machine.os.raw');
expect(titleInput.prop('placeholder')).toBe('machine.os.raw');
});
test('auto-fills custom title', async () => {
await openOptionsListEditor({ title: 'Custom Title' });
const titleInput = findTestSubject(controlEditor, 'control-editor-title-input');
expect(titleInput.prop('value')).toBe('Custom Title');
expect(titleInput.prop('placeholder')).toBe('machine.os.raw');
});
});
describe('selection options', () => {
test('selects default', async () => {
await openOptionsListEditor();
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__selectionOptionsRadioGroup'
);
expect(radioGroup.find('input#multi').prop('checked')).toBe(true);
expect(radioGroup.find('input#single').prop('checked')).toBe(false);
});
test('selects given', async () => {
await openOptionsListEditor({ singleSelect: true });
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__selectionOptionsRadioGroup'
);
expect(radioGroup.find('input#multi').prop('checked')).toBe(false);
expect(radioGroup.find('input#single').prop('checked')).toBe(true);
});
});
describe('search techniques', () => {
test('selects default', async () => {
await openOptionsListEditor();
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(radioGroup.find('input#prefix').prop('checked')).toBe(true);
expect(radioGroup.find('input#wildcard').prop('checked')).toBe(false);
});
test('selects given', async () => {
await openOptionsListEditor({ searchTechnique: 'wildcard' });
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(radioGroup.find('input#prefix').prop('checked')).toBe(false);
expect(radioGroup.find('input#wildcard').prop('checked')).toBe(true);
});
});
});
});

View file

@ -14,9 +14,10 @@
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useMount from 'react-use/lib/useMount';
import useAsync from 'react-use/lib/useAsync';
import deepEqual from 'fast-deep-equal';
import {
EuiFlyoutHeader,
@ -35,6 +36,7 @@ import {
EuiIcon,
EuiSwitch,
EuiTextColor,
EuiDescribedFormGroup,
} from '@elastic/eui';
import { DataViewField } from '@kbn/data-views-plugin/common';
import {
@ -46,7 +48,9 @@ import {
import { ControlGroupStrings } from '../control_group_strings';
import {
ControlEmbeddable,
ControlInput,
ControlWidth,
DataControlEditorChanges,
DataControlInput,
IEditableControlFactory,
} from '../../types';
@ -55,21 +59,16 @@ import { pluginServices } from '../../services';
import { getDataControlFieldRegistry } from './data_control_editor_tools';
import { useControlGroupContainer } from '../embeddable/control_group_container';
interface EditControlProps {
export interface EditControlProps {
embeddable?: ControlEmbeddable<DataControlInput>;
isCreate: boolean;
title?: string;
width: ControlWidth;
onSave: (type?: string) => void;
onSave: (changes: DataControlEditorChanges, type?: string) => void;
grow: boolean;
onCancel: () => void;
onCancel: (changes: DataControlEditorChanges) => void;
removeControl?: () => void;
updateGrow?: (grow: boolean) => void;
updateTitle: (title?: string) => void;
updateWidth: (newWidth: ControlWidth) => void;
getRelevantDataViewId?: () => string | undefined;
setLastUsedDataViewId?: (newDataViewId: string) => void;
onTypeEditorChange: (partial: Partial<DataControlInput>) => void;
}
const FieldPicker = withSuspense(LazyFieldPicker, null);
@ -78,16 +77,11 @@ const DataViewPicker = withSuspense(LazyDataViewPicker, null);
export const ControlEditor = ({
embeddable,
isCreate,
title,
width,
grow,
onSave,
onCancel,
removeControl,
updateGrow,
updateTitle,
updateWidth,
onTypeEditorChange,
getRelevantDataViewId,
setLastUsedDataViewId,
}: EditControlProps) => {
@ -99,15 +93,27 @@ export const ControlEditor = ({
const controlGroup = useControlGroupContainer();
const editorConfig = controlGroup.select((state) => state.componentState.editorConfig);
const [defaultTitle, setDefaultTitle] = useState<string>();
const [currentTitle, setCurrentTitle] = useState(title ?? '');
const [currentWidth, setCurrentWidth] = useState(width);
const [currentGrow, setCurrentGrow] = useState(grow);
const [currentWidth, setCurrentWidth] = useState(width);
const [defaultTitle, setDefaultTitle] = useState<string>();
const [currentTitle, setCurrentTitle] = useState(embeddable?.getTitle() ?? '');
const [controlEditorValid, setControlEditorValid] = useState(false);
const [selectedDataViewId, setSelectedDataViewId] = useState<string>();
const [selectedField, setSelectedField] = useState<string | undefined>(
embeddable ? embeddable.getInput().fieldName : undefined
);
const [selectedDataViewId, setSelectedDataViewId] = useState<string>();
const [customSettings, setCustomSettings] = useState<Partial<ControlInput>>();
const currentInput: Partial<DataControlInput> = useMemo(
() => ({
fieldName: selectedField,
dataViewId: selectedDataViewId,
title: currentTitle === '' ? defaultTitle ?? selectedField : currentTitle,
...customSettings,
}),
[currentTitle, defaultTitle, selectedField, selectedDataViewId, customSettings]
);
const startingInput = useRef(currentInput);
useMount(() => {
let mounted = true;
@ -119,8 +125,8 @@ export const ControlEditor = ({
const initialId =
embeddable?.getInput().dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId());
if (initialId) {
onTypeEditorChange({ dataViewId: initialId });
setSelectedDataViewId(initialId);
startingInput.current = { ...startingInput.current, dataViewId: initialId };
}
})();
return () => {
@ -172,130 +178,141 @@ export const ControlEditor = ({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="control-editor-flyout">
<EuiForm>
{!editorConfig?.hideDataViewSelector && (
<EuiFormRow label={ControlGroupStrings.manageControl.getDataViewTitle()}>
<DataViewPicker
dataViews={dataViewListItems}
selectedDataViewId={selectedDataViewId}
onChangeDataViewId={(dataViewId) => {
setLastUsedDataViewId?.(dataViewId);
if (dataViewId === selectedDataViewId) return;
onTypeEditorChange({ dataViewId });
setSelectedField(undefined);
setSelectedDataViewId(dataViewId);
<EuiForm fullWidth>
<EuiDescribedFormGroup
ratio="third"
title={<h2>{ControlGroupStrings.manageControl.dataSource.getFormGroupTitle()}</h2>}
description={ControlGroupStrings.manageControl.dataSource.getFormGroupDescription()}
>
{!editorConfig?.hideDataViewSelector && (
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getDataViewTitle()}>
<DataViewPicker
dataViews={dataViewListItems}
selectedDataViewId={selectedDataViewId}
onChangeDataViewId={(dataViewId) => {
setLastUsedDataViewId?.(dataViewId);
if (dataViewId === selectedDataViewId) return;
setSelectedField(undefined);
setSelectedDataViewId(dataViewId);
}}
trigger={{
label:
selectedDataView?.getName() ??
ControlGroupStrings.manageControl.dataSource.getSelectDataViewMessage(),
}}
selectableProps={{ isLoading: dataViewListLoading }}
/>
</EuiFormRow>
)}
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getFieldTitle()}>
<FieldPicker
filterPredicate={(field: DataViewField) => {
const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true;
return Boolean(fieldRegistry?.[field.name]) && customPredicate;
}}
trigger={{
label:
selectedDataView?.getName() ??
ControlGroupStrings.manageControl.getSelectDataViewMessage(),
selectedFieldName={selectedField}
dataView={selectedDataView}
onSelectField={(field) => {
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
setSelectedField(field.name);
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
}
}}
selectableProps={{ isLoading: dataViewListLoading }}
selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }}
/>
</EuiFormRow>
)}
<EuiFormRow label={ControlGroupStrings.manageControl.getFieldTitle()}>
<FieldPicker
filterPredicate={(field: DataViewField) => {
const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true;
return Boolean(fieldRegistry?.[field.name]) && customPredicate;
}}
selectedFieldName={selectedField}
dataView={selectedDataView}
onSelectField={(field) => {
onTypeEditorChange({
fieldName: field.name,
});
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
setSelectedField(field.name);
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
updateTitle(newDefaultTitle);
}
}}
selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }}
/>
</EuiFormRow>
<EuiFormRow label={ControlGroupStrings.manageControl.getControlTypeTitle()}>
{factory ? (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiIcon type={factory.getIconType()} />
</EuiFlexItem>
<EuiFlexItem data-test-subj="control-editor-type">
{factory.getDisplayName()}
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiTextColor color="subdued" data-test-subj="control-editor-type">
{ControlGroupStrings.manageControl.getSelectFieldMessage()}
</EuiTextColor>
)}
</EuiFormRow>
<EuiFormRow label={ControlGroupStrings.manageControl.getTitleInputTitle()}>
<EuiFieldText
data-test-subj="control-editor-title-input"
placeholder={defaultTitle}
value={currentTitle}
onChange={(e) => {
updateTitle(e.target.value || defaultTitle);
setCurrentTitle(e.target.value);
}}
/>
</EuiFormRow>
{!editorConfig?.hideWidthSettings && (
<EuiFormRow label={ControlGroupStrings.manageControl.getWidthInputTitle()}>
<>
<EuiButtonGroup
color="primary"
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={currentWidth}
onChange={(newWidth: string) => {
setCurrentWidth(newWidth as ControlWidth);
updateWidth(newWidth as ControlWidth);
}}
/>
{updateGrow && (
<>
<EuiSpacer size="s" />
<EuiSwitch
label={ControlGroupStrings.manageControl.getGrowSwitchTitle()}
color="primary"
checked={currentGrow}
onChange={() => {
setCurrentGrow(!currentGrow);
updateGrow(!currentGrow);
}}
data-test-subj="control-editor-grow-switch"
/>
</>
)}
</>
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getControlTypeTitle()}>
{factory ? (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type={factory.getIconType()} />
</EuiFlexItem>
<EuiFlexItem data-test-subj="control-editor-type">
{factory.getDisplayName()}
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiTextColor color="subdued" data-test-subj="control-editor-type">
{ControlGroupStrings.manageControl.dataSource.noControlTypeMessage()}
</EuiTextColor>
)}
</EuiFormRow>
)}
</EuiDescribedFormGroup>
<EuiDescribedFormGroup
ratio="third"
title={<h2>{ControlGroupStrings.manageControl.displaySettings.getFormGroupTitle()}</h2>}
description={ControlGroupStrings.manageControl.displaySettings.getFormGroupDescription()}
>
<EuiFormRow
label={ControlGroupStrings.manageControl.displaySettings.getTitleInputTitle()}
>
<EuiFieldText
data-test-subj="control-editor-title-input"
placeholder={defaultTitle}
value={currentTitle}
onChange={(e) => setCurrentTitle(e.target.value)}
/>
</EuiFormRow>
{!editorConfig?.hideWidthSettings && (
<EuiFormRow
label={ControlGroupStrings.manageControl.displaySettings.getWidthInputTitle()}
>
<>
<EuiButtonGroup
color="primary"
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={currentWidth}
onChange={(newWidth: string) => setCurrentWidth(newWidth as ControlWidth)}
/>
<EuiSpacer size="s" />
<EuiSwitch
label={ControlGroupStrings.manageControl.displaySettings.getGrowSwitchTitle()}
color="primary"
checked={currentGrow}
onChange={() => setCurrentGrow(!currentGrow)}
data-test-subj="control-editor-grow-switch"
/>
</>
</EuiFormRow>
)}
</EuiDescribedFormGroup>
{!editorConfig?.hideAdditionalSettings &&
CustomSettings &&
(factory as IEditableControlFactory).controlEditorOptionsComponent && (
<EuiFormRow label={ControlGroupStrings.manageControl.getControlSettingsTitle()}>
<EuiDescribedFormGroup
ratio="third"
title={
<h2>
{ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupTitle(
factory.getDisplayName()
)}
</h2>
}
description={ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupDescription(
factory.getDisplayName()
)}
data-test-subj="control-editor-custom-settings"
>
<CustomSettings
onChange={onTypeEditorChange}
onChange={(settings) => setCustomSettings(settings)}
initialInput={embeddable?.getInput()}
fieldType={fieldRegistry?.[selectedField].field.type}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
)}
{removeControl && (
<>
<EuiSpacer size="l" />
<EuiButtonEmpty
aria-label={`delete-${title}`}
aria-label={`delete-${currentInput.title}`}
iconType="trash"
flush="left"
color="danger"
onClick={() => {
onCancel();
onCancel({ input: currentInput, grow: currentGrow, width: currentWidth });
removeControl();
}}
>
@ -309,22 +326,32 @@ export const ControlEditor = ({
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`cancel-${title}`}
aria-label={`cancel-${currentInput.title}`}
data-test-subj="control-editor-cancel"
iconType="cross"
onClick={() => onCancel()}
onClick={() => {
const inputToReturn =
isCreate && deepEqual(startingInput.current, currentInput) ? {} : currentInput;
onCancel({
input: inputToReturn,
grow: currentGrow,
width: currentWidth,
});
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`save-${title}`}
aria-label={`save-${currentInput.title}`}
data-test-subj="control-editor-save"
iconType="check"
color="primary"
disabled={!controlEditorValid}
onClick={() => onSave(controlType)}
onClick={() =>
onSave({ input: currentInput, grow: currentGrow, width: currentWidth }, controlType)
}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>

View file

@ -7,6 +7,8 @@
*/
import React from 'react';
import { batch } from 'react-redux';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
@ -26,10 +28,10 @@ import {
} from '../../../common/control_group/control_group_constants';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { IEditableControlFactory } from '../../types';
import { DataControlEditorChanges, IEditableControlFactory } from '../../types';
import { ControlInputTransform } from '../../../common/types';
import { ControlGroupStrings } from '../control_group_strings';
import { DataControlInput, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
export function openAddDataControlFlyout(
this: ControlGroupContainer,
@ -45,9 +47,8 @@ export function openAddDataControlFlyout(
theme: { theme$ },
} = pluginServices.getServices();
let controlInput: Partial<DataControlInput> = {};
const onCancel = () => {
if (Object.keys(controlInput).length === 0) {
const onCancel = (changes?: DataControlEditorChanges) => {
if (!changes || Object.keys(changes.input).length === 0) {
this.closeAllFlyouts();
return;
}
@ -64,6 +65,53 @@ export function openAddDataControlFlyout(
});
};
const onSaveFlyout = async (changes: DataControlEditorChanges, type?: string) => {
this.closeAllFlyouts();
if (!type) {
return;
}
let controlInput = changes.input;
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (controlInputTransform) {
controlInput = controlInputTransform({ ...controlInput }, type);
}
const dataControlInput = {
grow: changes.grow,
width: changes.width,
...controlInput,
};
let newControl;
switch (type) {
case OPTIONS_LIST_CONTROL:
newControl = await this.addOptionsListControl(
dataControlInput as AddOptionsListControlProps
);
break;
case RANGE_SLIDER_CONTROL:
newControl = await this.addRangeSliderControl(
dataControlInput as AddRangeSliderControlProps
);
break;
default:
newControl = await this.addDataControlFromField(dataControlInput as AddDataControlProps);
}
if (onSave && !isErrorEmbeddable(newControl)) {
onSave(newControl.id);
}
batch(() => {
this.dispatch.setDefaultControlGrow(changes.grow);
this.dispatch.setDefaultControlWidth(changes.width);
});
};
const flyoutInstance = openFlyout(
toMountPoint(
<ControlGroupContainerContext.Provider value={this}>
@ -73,51 +121,8 @@ export function openAddDataControlFlyout(
isCreate={true}
width={this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={this.getInput().defaultControlGrow ?? DEFAULT_CONTROL_GROW}
updateTitle={(newTitle) => (controlInput.title = newTitle)}
updateWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
updateGrow={(defaultControlGrow: boolean) => this.updateInput({ defaultControlGrow })}
onSave={async (type) => {
this.closeAllFlyouts();
if (!type) {
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (controlInputTransform) {
controlInput = controlInputTransform({ ...controlInput }, type);
}
let newControl;
switch (type) {
case OPTIONS_LIST_CONTROL:
newControl = await this.addOptionsListControl(
controlInput as AddOptionsListControlProps
);
break;
case RANGE_SLIDER_CONTROL:
newControl = await this.addRangeSliderControl(
controlInput as AddRangeSliderControlProps
);
break;
default:
newControl = await this.addDataControlFromField(
controlInput as AddDataControlProps
);
}
if (onSave && !isErrorEmbeddable(newControl)) {
onSave(newControl.id);
}
}}
onSave={onSaveFlyout}
onCancel={onCancel}
onTypeEditorChange={(partialInput) =>
(controlInput = { ...controlInput, ...partialInput })
}
/>
</ControlGroupContainerContext.Provider>,
{ theme$ }
@ -128,8 +133,6 @@ export function openAddDataControlFlyout(
onClose: () => {
onCancel();
},
// @ts-ignore - TODO: Remove this once https://github.com/elastic/eui/pull/6645 lands in Kibana
focusTrapProps: { scrollLock: true },
}
);
setFlyoutRef(flyoutInstance);

View file

@ -57,8 +57,6 @@ export function openEditControlGroupFlyout(this: ControlGroupContainer) {
onClose: () => {
this.closeAllFlyouts();
},
// @ts-ignore - TODO: Remove this once https://github.com/elastic/eui/pull/6645 lands in Kibana
focusTrapProps: { scrollLock: true },
}
);
setFlyoutRef(flyoutInstance);

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import useAsync from 'react-use/lib/useAsync';
import React, { useEffect, useState } from 'react';
import {
@ -14,22 +15,79 @@ import {
EuiFormRow,
EuiIconTip,
EuiSwitch,
EuiSwitchEvent,
Direction,
EuiRadioGroup,
EuiLoadingSpinner,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { pluginServices } from '../../services';
import {
OptionsListSortBy,
getCompatibleSortingTypes,
OPTIONS_LIST_DEFAULT_SORT,
OptionsListSortBy,
} from '../../../common/options_list/suggestions_sorting';
import { OptionsListStrings } from './options_list_strings';
import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
import {
OptionsListSearchTechnique,
OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE,
} from '../../../common/options_list/types';
const TooltipText = ({ label, tooltip }: { label: string; tooltip: string }) => (
<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>
);
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 searchOptions = [
{
id: 'prefix',
label: (
<TooltipText
label={OptionsListStrings.editor.searchTypes.prefix.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.prefix.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__prefixSearchOptionAdditionalSetting',
},
{
id: 'wildcard',
label: (
<TooltipText
label={OptionsListStrings.editor.searchTypes.wildcard.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.wildcard.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__wildcardSearchOptionAdditionalSetting',
},
];
interface OptionsListEditorState {
sortDirection: Direction;
runPastTimeout?: boolean;
searchTechnique?: OptionsListSearchTechnique;
singleSelect?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
@ -37,11 +95,6 @@ interface OptionsListEditorState {
sortBy: OptionsListSortBy;
}
interface SwitchProps {
checked: boolean;
onChange: (event: EuiSwitchEvent) => void;
}
export const OptionsListEditorOptions = ({
initialInput,
onChange,
@ -50,6 +103,7 @@ export const OptionsListEditorOptions = ({
const [state, setState] = useState<OptionsListEditorState>({
sortDirection: initialInput?.sort?.direction ?? OPTIONS_LIST_DEFAULT_SORT.direction,
sortBy: initialInput?.sort?.by ?? OPTIONS_LIST_DEFAULT_SORT.by,
searchTechnique: initialInput?.searchTechnique,
runPastTimeout: initialInput?.runPastTimeout,
singleSelect: initialInput?.singleSelect,
hideExclude: initialInput?.hideExclude,
@ -57,6 +111,12 @@ export const OptionsListEditorOptions = ({
hideSort: initialInput?.hideSort,
});
const { loading: waitingForAllowExpensiveQueries, value: allowExpensiveQueries } =
useAsync(async () => {
const { optionsList: optionsListService } = pluginServices.getServices();
return optionsListService.getAllowExpensiveQueries();
}, []);
useEffect(() => {
// when field type changes, ensure that the selected sort type is still valid
if (!getCompatibleSortingTypes(fieldType).includes(state.sortBy)) {
@ -69,53 +129,57 @@ export const OptionsListEditorOptions = ({
}
}, [fieldType, onChange, state.sortBy]);
const SwitchWithTooltip = ({
switchProps,
label,
tooltip,
}: {
switchProps: SwitchProps;
label: string;
tooltip: string;
}) => (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiSwitch label={label} {...switchProps} />
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip content={tooltip} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
);
return (
<>
<EuiFormRow>
<EuiSwitch
label={OptionsListStrings.editor.getAllowMultiselectTitle()}
checked={!state.singleSelect}
onChange={() => {
onChange({ singleSelect: !state.singleSelect });
setState((s) => ({ ...s, singleSelect: !s.singleSelect }));
<EuiFormRow
label={OptionsListStrings.editor.getSelectionOptionsTitle()}
data-test-subj="optionsListControl__selectionOptionsRadioGroup"
>
<EuiRadioGroup
options={selectionOptions}
idSelected={state.singleSelect ? 'single' : 'multi'}
onChange={(id) => {
const newSingleSelect = id === 'single';
onChange({ singleSelect: newSingleSelect });
setState((s) => ({ ...s, singleSelect: newSingleSelect }));
}}
data-test-subj={'optionsListControl__allowMultipleAdditionalSetting'}
/>
</EuiFormRow>
<EuiFormRow>
<SwitchWithTooltip
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
tooltip={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
switchProps={{
checked: Boolean(state.runPastTimeout),
onChange: () => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
},
{waitingForAllowExpensiveQueries ? (
<EuiFormRow>
<EuiLoadingSpinner size="l" />
</EuiFormRow>
) : (
allowExpensiveQueries &&
fieldType !== 'ip' && (
<EuiFormRow
label={OptionsListStrings.editor.getSearchOptionsTitle()}
data-test-subj="optionsListControl__searchOptionsRadioGroup"
>
<EuiRadioGroup
options={searchOptions}
idSelected={state.searchTechnique ?? OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE}
onChange={(id) => {
const searchTechnique = id as OptionsListSearchTechnique;
onChange({ searchTechnique });
setState((s) => ({ ...s, searchTechnique }));
}}
/>
</EuiFormRow>
)
)}
<EuiFormRow label={OptionsListStrings.editor.getAdditionalSettingsTitle()}>
<EuiSwitch
label={
<TooltipText
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
tooltip={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
/>
}
checked={Boolean(state.runPastTimeout)}
onChange={() => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
}}
data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'}
/>

View file

@ -21,6 +21,7 @@ import {
import { OptionsListStrings } from './options_list_strings';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
import { OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE } from '../../../common/options_list/types';
interface OptionsListPopoverProps {
showOnlySelected: boolean;
@ -40,6 +41,8 @@ export const OptionsListPopoverActionBar = ({
const searchString = optionsList.select((state) => state.componentState.searchString);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
@ -59,7 +62,9 @@ export const OptionsListPopoverActionBar = ({
onChange={(event) => updateSearchString(event.target.value)}
value={searchString.value}
data-test-subj="optionsList-control-search-input"
placeholder={OptionsListStrings.popover.getSearchPlaceholder()}
placeholder={OptionsListStrings.popover.searchPlaceholder[
searchTechnique ?? OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE
].getPlaceholderText()}
/>
</EuiFlexItem>
{!hideSort && (

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import {
EuiButtonGroupOptionProps,
@ -33,10 +33,26 @@ import { useOptionsList } from '../embeddable/options_list_embeddable';
interface OptionsListSortingPopoverProps {
showOnlySelected: boolean;
}
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,
}: OptionsListSortingPopoverProps) => {
@ -59,28 +75,16 @@ export const OptionsListPopoverSortingButton = ({
});
});
const sortOrderOptions: EuiButtonGroupOptionProps[] = [
{
id: 'asc',
iconType: `sortAscending`,
'data-test-subj': `optionsList__sortOrder_asc`,
label: OptionsListStrings.editorAndPopover.sortOrder.asc.getSortOrderLabel(),
const onSortByChange = useCallback(
(updatedOptions: SortByItem[]) => {
setSortByOptions(updatedOptions);
const selectedOption = updatedOptions.find(({ checked }) => checked === 'on');
if (selectedOption) {
optionsList.dispatch.setSort({ by: selectedOption.data.sortBy });
}
},
{
id: 'desc',
iconType: `sortDescending`,
'data-test-subj': `optionsList__sortOrder_desc`,
label: OptionsListStrings.editorAndPopover.sortOrder.desc.getSortOrderLabel(),
},
];
const onSortByChange = (updatedOptions: SortByItem[]) => {
setSortByOptions(updatedOptions);
const selectedOption = updatedOptions.find(({ checked }) => checked === 'on');
if (selectedOption) {
optionsList.dispatch.setSort({ by: selectedOption.data.sortBy });
}
};
[optionsList.dispatch]
);
const SortButton = () => (
<EuiButtonEmpty

View file

@ -9,7 +9,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiSelectable } from '@elastic/eui';
import { EuiHighlight, EuiSelectable } from '@elastic/eui';
import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
@ -159,6 +159,13 @@ export const OptionsListPopoverSuggestions = ({
<div ref={listRef}>
<EuiSelectable
options={selectableOptions}
renderOption={(option) => {
return (
<EuiHighlight search={option.key === 'exists-option' ? '' : searchString.value}>
{option.label}
</EuiHighlight>
);
}}
listProps={{ onFocusBadge: false }}
aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel(
fieldName,

View file

@ -28,9 +28,54 @@ export const OptionsListStrings = {
}),
},
editor: {
getAllowMultiselectTitle: () =>
i18n.translate('controls.optionsList.editor.allowMultiselectTitle', {
defaultMessage: 'Allow multiple selections in dropdown',
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.',
}),
},
},
getAdditionalSettingsTitle: () =>
i18n.translate('controls.optionsList.editor.additionalSettingsTitle', {
defaultMessage: `Additional settings`,
}),
getRunPastTimeoutTitle: () =>
i18n.translate('controls.optionsList.editor.runPastTimeout', {
@ -88,10 +133,20 @@ export const OptionsListStrings = {
i18n.translate('controls.optionsList.popover.clearAllSelectionsTitle', {
defaultMessage: 'Clear selections',
}),
getSearchPlaceholder: () =>
i18n.translate('controls.optionsList.popover.searchPlaceholder', {
defaultMessage: 'Search',
}),
searchPlaceholder: {
prefix: {
getPlaceholderText: () =>
i18n.translate('controls.optionsList.popover.prefixSearchPlaceholder', {
defaultMessage: 'Starts with...',
}),
},
wildcard: {
getPlaceholderText: () =>
i18n.translate('controls.optionsList.popover.wildcardSearchPlaceholder', {
defaultMessage: 'Contains...',
}),
},
},
getCardinalityLabel: (totalOptions: number) =>
i18n.translate('controls.optionsList.popover.cardinalityLabel', {
defaultMessage:

View file

@ -159,6 +159,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations),
lastReloadRequestTime: newInput.lastReloadRequestTime,
existsSelected: newInput.existsSelected,
searchTechnique: newInput.searchTechnique,
dataViewId: newInput.dataViewId,
fieldName: newInput.fieldName,
timeRange: newInput.timeRange,
@ -183,6 +184,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.runOptionsListQuery();
})
);
// fetch more options when reaching the bottom of the available options
this.subscriptions.add(
loadMorePipe.subscribe((size) => {
@ -286,7 +288,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
const {
componentState: { searchString, allowExpensiveQueries },
explicitInput: { selectedOptions, runPastTimeout, existsSelected, sort },
explicitInput: { selectedOptions, runPastTimeout, existsSelected, sort, searchTechnique },
} = this.getState();
this.dispatch.setLoading(true);
if (searchString.valid) {
@ -318,6 +320,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
filters,
dataView,
timeRange,
searchTechnique,
runPastTimeout,
selectedOptions,
allowExpensiveQueries,

View file

@ -50,6 +50,7 @@ class OptionsListService implements ControlsOptionsListService {
searchString,
runPastTimeout,
selectedOptions,
searchTechnique,
field: { name: fieldName },
dataView: { title: dataViewTitle },
} = request;
@ -60,6 +61,7 @@ class OptionsListService implements ControlsOptionsListService {
JSON.stringify(filters),
JSON.stringify(query),
JSON.stringify(sort),
searchTechnique,
runPastTimeout,
dataViewTitle,
searchString,

View file

@ -21,7 +21,7 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { ControlInput } from '../common/types';
import { ControlInput, ControlWidth, DataControlInput } from '../common/types';
import { ControlsServiceType } from './services/controls/types';
export interface CommonControlOutput {
@ -74,6 +74,12 @@ export interface DataControlFieldRegistry {
[fieldName: string]: DataControlField;
}
export interface DataControlEditorChanges {
input: Partial<DataControlInput>;
width?: ControlWidth;
grow?: boolean;
}
/**
* Plugin types
*/

View file

@ -53,7 +53,6 @@ describe('options list cheap queries', () => {
"suggestions": Object {
"terms": Object {
"field": "coolTestField.keyword",
"include": ".*",
"order": Object {
"_count": "asc",
},

View file

@ -13,7 +13,7 @@ import { OptionsListRequestBody, OptionsListSuggestions } from '../../common/opt
import { getIpRangeQuery, type IpRangeQuery } from '../../common/options_list/ip_search';
import { EsBucket, OptionsListSuggestionAggregationBuilder } from './types';
import {
getEscapedQuery,
getEscapedRegexQuery,
getIpBuckets,
getSortType,
} from './options_list_suggestion_query_helpers';
@ -44,7 +44,9 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat
suggestions: {
terms: {
field: fieldName,
include: `${getEscapedQuery(searchString)}.*`,
...(searchString && searchString.length > 0
? { include: `${getEscapedRegexQuery(searchString)}.*` }
: {}),
shard_size: 10,
order: getSortType(sort),
},
@ -101,7 +103,7 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat
],
};
if (searchString) {
if (searchString && searchString.length > 0) {
ipRangeQuery = getIpRangeQuery(searchString);
if (!ipRangeQuery.validSearch) {
// ideally should be prevented on the client side but, if somehow an invalid search gets through to the server,
@ -180,7 +182,9 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat
suggestions: {
terms: {
field: fieldName,
include: `${getEscapedQuery(searchString)}.*`,
...(searchString && searchString.length > 0
? { include: `${getEscapedRegexQuery(searchString)}.*` }
: {}),
shard_size: 10,
order: getSortType(sort),
},

View file

@ -115,6 +115,100 @@ describe('options list expensive queries', () => {
`);
});
test('test keyword field, with wildcard search and basic search string', () => {
const optionsListRequestBodyMock: OptionsListRequestBody = {
size: 10,
searchString: 'c',
searchTechnique: 'wildcard',
allowExpensiveQueries: true,
fieldName: 'coolTestField.keyword',
sort: { by: '_key', direction: 'desc' },
fieldSpec: { aggregatable: true } as unknown as FieldSpec,
};
const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder(
optionsListRequestBodyMock
);
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
.toMatchInlineSnapshot(`
Object {
"filteredSuggestions": Object {
"aggs": Object {
"suggestions": Object {
"terms": Object {
"field": "coolTestField.keyword",
"order": Object {
"_key": "desc",
},
"shard_size": 10,
"size": 10,
},
},
"unique_terms": Object {
"cardinality": Object {
"field": "coolTestField.keyword",
},
},
},
"filter": Object {
"wildcard": Object {
"coolTestField.keyword": Object {
"case_insensitive": true,
"value": "*c*",
},
},
},
},
}
`);
});
test('test keyword field, with wildcard search and search string that needs to be escaped', () => {
const optionsListRequestBodyMock: OptionsListRequestBody = {
size: 10,
searchString: '.c?o&o[l*',
searchTechnique: 'wildcard',
allowExpensiveQueries: true,
fieldName: 'coolTestField.keyword',
sort: { by: '_key', direction: 'desc' },
fieldSpec: { aggregatable: true } as unknown as FieldSpec,
};
const suggestionAggBuilder = getExpensiveSuggestionAggregationBuilder(
optionsListRequestBodyMock
);
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
.toMatchInlineSnapshot(`
Object {
"filteredSuggestions": Object {
"aggs": Object {
"suggestions": Object {
"terms": Object {
"field": "coolTestField.keyword",
"order": Object {
"_key": "desc",
},
"shard_size": 10,
"size": 10,
},
},
"unique_terms": Object {
"cardinality": Object {
"field": "coolTestField.keyword",
},
},
},
"filter": Object {
"wildcard": Object {
"coolTestField.keyword": Object {
"case_insensitive": true,
"value": "*.c\\\\?o&o[l\\\\**",
},
},
},
},
}
`);
});
test('test nested field, with a search string', () => {
const optionsListRequestBodyMock: OptionsListRequestBody = {
size: 10,

View file

@ -9,10 +9,18 @@
import { get } from 'lodash';
import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common';
import { OptionsListRequestBody, OptionsListSuggestions } from '../../common/options_list/types';
import {
OptionsListRequestBody,
OptionsListSuggestions,
OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE,
} from '../../common/options_list/types';
import { getIpRangeQuery, type IpRangeQuery } from '../../common/options_list/ip_search';
import { EsBucket, OptionsListSuggestionAggregationBuilder } from './types';
import { getIpBuckets, getSortType } from './options_list_suggestion_query_helpers';
import {
getEscapedWildcardQuery,
getIpBuckets,
getSortType,
} from './options_list_suggestion_query_helpers';
/**
* Suggestion aggregations
@ -34,6 +42,7 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr
*/
textOrKeywordOrNested: {
buildAggregation: ({
searchTechnique,
searchString,
fieldName,
fieldSpec,
@ -56,13 +65,16 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr
},
},
};
if (searchString) {
if (searchString && searchString.length > 0) {
textOrKeywordQuery = {
filteredSuggestions: {
filter: {
prefix: {
[(searchTechnique ?? OPTIONS_LIST_DEFAULT_SEARCH_TECHNIQUE) as string]: {
[fieldName]: {
value: searchString,
value:
searchTechnique === 'wildcard'
? `*${getEscapedWildcardQuery(searchString)}*`
: searchString,
case_insensitive: true,
},
},
@ -146,7 +158,7 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr
],
};
if (searchString) {
if (searchString && searchString.length > 0) {
ipRangeQuery = getIpRangeQuery(searchString);
if (!ipRangeQuery.validSearch) {
// ideally should be prevented on the client side but, if somehow an invalid search gets through to the server,

View file

@ -20,9 +20,12 @@ export const getSortType = (sort?: OptionsListSortingType) => {
: { [OPTIONS_LIST_DEFAULT_SORT.by]: OPTIONS_LIST_DEFAULT_SORT.direction };
};
export const getEscapedQuery = (q: string = '') =>
export const getEscapedRegexQuery = (q: string = '') =>
q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`);
export const getEscapedWildcardQuery = (q: string = '') =>
q.replace(/[?*]/g, (match) => `\\${match}`);
export const getIpBuckets = (
rawEsResult: any,
combinedBuckets: EsBucket[],

View file

@ -50,7 +50,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const growSwitch = await testSubjects.find('control-editor-grow-switch');
expect(await growSwitch.getAttribute('aria-checked')).to.be('true');
await testSubjects.click('control-editor-cancel');
await testSubjects.click('confirmModalConfirmButton');
});
it('sets default to width and grow of last created control', async () => {
@ -78,7 +77,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const growSwitch = await testSubjects.find('control-editor-grow-switch');
expect(await growSwitch.getAttribute('aria-checked')).to.be('false');
await testSubjects.click('control-editor-cancel');
await testSubjects.click('confirmModalConfirmButton');
});
});

View file

@ -155,7 +155,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'animals-*'
);
await testSubjects.missingOrFail('field-picker-select-isDog');
await dashboardControls.controlEditorCancel(true);
await dashboardControls.controlEditorCancel();
});
});
});

View file

@ -37,79 +37,147 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
controlId = (await dashboardControls.getAllControlIds())[0];
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
});
after(async () => {
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboardControls.deleteAllControls();
await dashboard.clickQuickSave();
});
it('sort alphabetically - descending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'desc' });
const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort()
.reverse()
.reduce((result, key) => {
return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
describe('sorting', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
});
after(async () => {
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('sort alphabetically - descending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'desc' });
const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort()
.reverse()
.reduce((result, key) => {
return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
});
it('sort alphabetically - ascending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'asc' });
const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort()
.reduce((result, key) => {
return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
});
it('sort by document count - descending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{
suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, // keys are already sorted descending by doc count
invalidSelections: [],
},
true
);
});
it('sort by document count - ascending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'asc' });
const sortedSuggestions = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort(([, docCountA], [, docCountB]) => {
return docCountB - docCountA;
})
.reduce((result, [key, docCount]) => {
return { ...result, [key]: docCount };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
});
it('non-default sort value should cause unsaved changes', async () => {
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('returning to default sort value should remove unsaved changes', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
});
});
it('sort alphabetically - ascending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'asc' });
const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort()
.reduce((result, key) => {
return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
describe('searching', () => {
it('prefix searching works as expected', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSearchForOption('G');
const startsWithG = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS).reduce(
(result, [key, docCount]) => {
if (key[0] === 'g') return { ...result, [key]: docCount };
return { ...result };
},
{}
);
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{
suggestions: startsWithG,
invalidSelections: [],
},
true
);
await dashboardControls.optionsListPopoverClearSearch();
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
});
it('sort by document count - descending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
it('wildcard searching causes unsaved changes', async () => {
await dashboardControls.editExistingControl(controlId);
await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'wildcard' });
await dashboardControls.controlEditorSave();
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('wildcard searching works as expected', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSearchForOption('r');
const containsR = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS).reduce(
(result, [key, docCount]) => {
if (key.includes('r')) return { ...result, [key]: docCount };
return { ...result };
},
{}
);
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{
suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, // keys are already sorted descending by doc count
suggestions: containsR,
invalidSelections: [],
},
true
);
await dashboardControls.optionsListPopoverClearSearch();
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('sort by document count - ascending', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'asc' });
const sortedSuggestions = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS)
.sort(([, docCountA], [, docCountB]) => {
return docCountB - docCountA;
})
.reduce((result, [key, docCount]) => {
return { ...result, [key]: docCount };
}, {});
await dashboardControls.ensureAvailableOptionsEqual(
controlId,
{ suggestions: sortedSuggestions, invalidSelections: [] },
true
);
});
it('non-default sort value should cause unsaved changes', async () => {
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('returning to default sort value should remove unsaved changes', async () => {
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
it('returning to default search technqiue should remove unsaved changes', async () => {
await dashboardControls.editExistingControl(controlId);
await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'prefix' });
await dashboardControls.controlEditorSave();
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
});
});

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import { FtrProviderContext } from '../../../ftr_provider_context';
@ -31,14 +32,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.controlEditorSave();
};
const replaceWithOptionsList = async (controlId: string) => {
await changeFieldType(controlId, 'sound.keyword', OPTIONS_LIST_CONTROL);
const replaceWithOptionsList = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, OPTIONS_LIST_CONTROL);
await testSubjects.waitForEnabled(`optionsList-control-${controlId}`);
await dashboardControls.verifyControlType(controlId, 'optionsList-control');
};
const replaceWithRangeSlider = async (controlId: string) => {
await changeFieldType(controlId, 'weightLbs', RANGE_SLIDER_CONTROL);
const replaceWithRangeSlider = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL);
await retry.try(async () => {
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.verifyControlType(controlId, 'range-slider-control');
@ -76,8 +77,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.clearUnsavedChanges();
});
it('with range slider', async () => {
await replaceWithRangeSlider(controlId);
it('with range slider - default title', async () => {
await replaceWithRangeSlider(controlId, 'weightLbs');
const titles = await dashboardControls.getAllControlTitles();
expect(titles[0]).to.be('weightLbs');
});
it('with options list - custom title', async () => {
await dashboardControls.editExistingControl(controlId);
await dashboardControls.controlEditorSetTitle('Custom title');
await dashboardControls.controlEditorSave();
await replaceWithRangeSlider(controlId, 'weightLbs');
const titles = await dashboardControls.getAllControlTitles();
expect(titles[0]).to.be('Custom title');
});
});
@ -97,8 +110,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.clearUnsavedChanges();
});
it('with options list', async () => {
await replaceWithOptionsList(controlId);
it('with options list - default title', async () => {
await replaceWithOptionsList(controlId, 'sound.keyword');
const titles = await dashboardControls.getAllControlTitles();
expect(titles[0]).to.be('sound.keyword');
});
it('with options list - custom title', async () => {
await dashboardControls.editExistingControl(controlId);
await dashboardControls.controlEditorSetTitle('Custom title');
await dashboardControls.controlEditorSave();
await replaceWithOptionsList(controlId, 'sound.keyword');
const titles = await dashboardControls.getAllControlTitles();
expect(titles[0]).to.be('Custom title');
});
});
});

View file

@ -12,6 +12,7 @@ import {
RANGE_SLIDER_CONTROL,
ControlWidth,
} from '@kbn/controls-plugin/common';
import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/types';
import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types';
import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting';
@ -19,12 +20,13 @@ import { WebElementWrapper } from '../services/lib/web_element_wrapper';
import { FtrService } from '../ftr_provider_context';
const CONTROL_DISPLAY_NAMES: { [key: string]: string } = {
default: 'Please select a field',
default: 'No field selected yet',
[OPTIONS_LIST_CONTROL]: 'Options list',
[RANGE_SLIDER_CONTROL]: 'Range slider',
};
interface OptionsListAdditionalSettings {
searchTechnique?: OptionsListSearchTechnique;
defaultSortType?: OptionsListSortingType;
ignoreTimeout?: boolean;
allowMultiple?: boolean;
@ -345,12 +347,21 @@ export class DashboardPageControls extends FtrService {
public async optionsListSetAdditionalSettings({
ignoreTimeout,
allowMultiple,
searchTechnique,
}: OptionsListAdditionalSettings) {
const getSettingTestSubject = (setting: string) =>
`optionsListControl__${setting}AdditionalSetting`;
if (allowMultiple) await this.testSubjects.click(getSettingTestSubject('allowMultiple'));
if (ignoreTimeout) await this.testSubjects.click(getSettingTestSubject('runPastTimeout'));
if (searchTechnique) {
this.log.debug(`clicking search technique: ${searchTechnique}`);
await this.find.clickByCssSelector(
`[data-test-subj=${getSettingTestSubject(
`${searchTechnique}SearchOption`
)}] label[for="${searchTechnique}"]`
);
}
}
public async optionsListGetSelectionsString(controlId: string) {
@ -559,10 +570,10 @@ export class DashboardPageControls extends FtrService {
});
}
public async controlEditorCancel(confirm?: boolean) {
public async controlEditorCancel() {
this.log.debug(`Canceling changes in control editor`);
await this.testSubjects.click(`control-editor-cancel`);
if (confirm) {
if (await this.testSubjects.exists('confirmModalTitleText')) {
await this.common.clickConfirmOnModal();
}
}
@ -605,7 +616,7 @@ export class DashboardPageControls extends FtrService {
}
const dataViewName = (await this.testSubjects.find('open-data-view-picker')).getVisibleText();
if (openAndCloseFlyout) {
await this.controlEditorCancel(true);
await this.controlEditorCancel();
}
return dataViewName;
}

View file

@ -415,18 +415,16 @@
"controls.controlGroup.floatingActions.editTitle": "Modifier le contrôle",
"controls.controlGroup.floatingActions.removeTitle": "Retirer le contrôle",
"controls.controlGroup.manageControl.cancelTitle": "Annuler",
"controls.controlGroup.manageControl.controlSettingsTitle": "Paramètres supplémentaires",
"controls.controlGroup.manageControl.controlTypesTitle": "Type de contrôle",
"controls.controlGroup.manageControl.dataSource.controlTypesTitle": "Type de contrôle",
"controls.controlGroup.manageControl.createFlyoutTitle": "Créer un contrôle",
"controls.controlGroup.manageControl.dataViewTitle": "Vue de données",
"controls.controlGroup.manageControl.dataSource.dataViewTitle": "Vue de données",
"controls.controlGroup.manageControl.editFlyoutTitle": "Modifier le contrôle",
"controls.controlGroup.manageControl.fielditle": "Champ",
"controls.controlGroup.manageControl.growSwitchTitle": "Augmenter la largeur en fonction de l'espace disponible",
"controls.controlGroup.manageControl.dataSource.fieldTitle": "Champ",
"controls.controlGroup.manageControl.displaySettings.growSwitchTitle": "Augmenter la largeur en fonction de l'espace disponible",
"controls.controlGroup.manageControl.saveChangesTitle": "Enregistrer et fermer",
"controls.controlGroup.manageControl.selectDataViewMessage": "Veuillez sélectionner une vue de données",
"controls.controlGroup.manageControl.selectFieldMessage": "Veuillez sélectionner un champ",
"controls.controlGroup.manageControl.titleInputTitle": "Étiquette",
"controls.controlGroup.manageControl.widthInputTitle": "Largeur minimale",
"controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "Veuillez sélectionner une vue de données",
"controls.controlGroup.manageControl.displaySettings.titleInputTitle": "Étiquette",
"controls.controlGroup.manageControl.displaySettings.widthInputTitle": "Largeur minimale",
"controls.controlGroup.management.addControl": "Ajouter un contrôle",
"controls.controlGroup.management.delete": "Supprimer le contrôle",
"controls.controlGroup.management.delete.cancel": "Annuler",
@ -472,7 +470,6 @@
"controls.optionsList.control.separator": ", ",
"controls.optionsList.description": "Ajoutez un menu pour la sélection de valeurs de champ.",
"controls.optionsList.displayName": "Liste des options",
"controls.optionsList.editor.allowMultiselectTitle": "Permettre des sélections multiples dans une liste déroulante",
"controls.optionsList.editor.runPastTimeout": "Ignorer le délai d'expiration pour les résultats",
"controls.optionsList.editor.runPastTimeout.tooltip": "Attendre que la liste soit complète pour afficher les résultats. Ce paramètre est utile pour les ensembles de données volumineux, mais le remplissage des résultats peut prendre plus de temps.",
"controls.optionsList.popover.allOptionsTitle": "Afficher toutes les options",
@ -485,7 +482,6 @@
"controls.optionsList.popover.includeLabel": "Inclure",
"controls.optionsList.popover.invalidSelectionScreenReaderText": "Sélection non valide.",
"controls.optionsList.popover.loadingMore": "Chargement d'options supplémentaires...",
"controls.optionsList.popover.searchPlaceholder": "Recherche",
"controls.optionsList.popover.selectedOptionsTitle": "Afficher uniquement les options sélectionnées",
"controls.optionsList.popover.selectionsEmpty": "Vous n'avez pas de sélections",
"controls.optionsList.popover.sortBy.alphabetical": "Par ordre alphabétique",

View file

@ -415,18 +415,16 @@
"controls.controlGroup.floatingActions.editTitle": "コントロールを編集",
"controls.controlGroup.floatingActions.removeTitle": "コントロールを削除",
"controls.controlGroup.manageControl.cancelTitle": "キャンセル",
"controls.controlGroup.manageControl.controlSettingsTitle": "追加設定",
"controls.controlGroup.manageControl.controlTypesTitle": "コントロールタイプ",
"controls.controlGroup.manageControl.dataSource.controlTypesTitle": "コントロールタイプ",
"controls.controlGroup.manageControl.createFlyoutTitle": "コントロールを作成",
"controls.controlGroup.manageControl.dataViewTitle": "データビュー",
"controls.controlGroup.manageControl.dataSource.dataViewTitle": "データビュー",
"controls.controlGroup.manageControl.editFlyoutTitle": "コントロールを編集",
"controls.controlGroup.manageControl.fielditle": "フィールド",
"controls.controlGroup.manageControl.growSwitchTitle": "空きスペースに合わせて幅を拡大",
"controls.controlGroup.manageControl.dataSource.fieldTitle": "フィールド",
"controls.controlGroup.manageControl.displaySettings.growSwitchTitle": "空きスペースに合わせて幅を拡大",
"controls.controlGroup.manageControl.saveChangesTitle": "保存して閉じる",
"controls.controlGroup.manageControl.selectDataViewMessage": "データビューを選択してください",
"controls.controlGroup.manageControl.selectFieldMessage": "フィールドを選択してください",
"controls.controlGroup.manageControl.titleInputTitle": "ラベル",
"controls.controlGroup.manageControl.widthInputTitle": "最小幅",
"controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "データビューを選択してください",
"controls.controlGroup.manageControl.displaySettings.titleInputTitle": "ラベル",
"controls.controlGroup.manageControl.displaySettings.widthInputTitle": "最小幅",
"controls.controlGroup.management.addControl": "コントロールを追加",
"controls.controlGroup.management.delete": "コントロールを削除",
"controls.controlGroup.management.delete.cancel": "キャンセル",
@ -472,7 +470,6 @@
"controls.optionsList.control.separator": "、",
"controls.optionsList.description": "フィールド値を選択するメニューを追加",
"controls.optionsList.displayName": "オプションリスト",
"controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可",
"controls.optionsList.editor.runPastTimeout": "結果のタイムアウトを無視",
"controls.optionsList.editor.runPastTimeout.tooltip": "リストが入力されるまで待機してから、結果を表示します。この設定は大きいデータセットで有用です。ただし、結果の入力に時間がかかる場合があります。",
"controls.optionsList.popover.allOptionsTitle": "すべてのオプションを表示",
@ -485,7 +482,6 @@
"controls.optionsList.popover.includeLabel": "含める",
"controls.optionsList.popover.invalidSelectionScreenReaderText": "無効な選択です。",
"controls.optionsList.popover.loadingMore": "その他のオプションを読み込んでいます...",
"controls.optionsList.popover.searchPlaceholder": "検索",
"controls.optionsList.popover.selectedOptionsTitle": "選択したオプションのみを表示",
"controls.optionsList.popover.selectionsEmpty": "選択されていません",
"controls.optionsList.popover.sortBy.alphabetical": "アルファベット順",

View file

@ -415,18 +415,16 @@
"controls.controlGroup.floatingActions.editTitle": "编辑控件",
"controls.controlGroup.floatingActions.removeTitle": "删除控件",
"controls.controlGroup.manageControl.cancelTitle": "取消",
"controls.controlGroup.manageControl.controlSettingsTitle": "其他设置",
"controls.controlGroup.manageControl.controlTypesTitle": "控件类型",
"controls.controlGroup.manageControl.dataSource.controlTypesTitle": "控件类型",
"controls.controlGroup.manageControl.createFlyoutTitle": "创建控件",
"controls.controlGroup.manageControl.dataViewTitle": "数据视图",
"controls.controlGroup.manageControl.dataSource.dataViewTitle": "数据视图",
"controls.controlGroup.manageControl.editFlyoutTitle": "编辑控件",
"controls.controlGroup.manageControl.fielditle": "字段",
"controls.controlGroup.manageControl.growSwitchTitle": "扩大宽度以适应可用空间",
"controls.controlGroup.manageControl.dataSource.fieldTitle": "字段",
"controls.controlGroup.manageControl.displaySettings.growSwitchTitle": "扩大宽度以适应可用空间",
"controls.controlGroup.manageControl.saveChangesTitle": "保存并关闭",
"controls.controlGroup.manageControl.selectDataViewMessage": "请选择数据视图",
"controls.controlGroup.manageControl.selectFieldMessage": "请选择字段",
"controls.controlGroup.manageControl.titleInputTitle": "标签",
"controls.controlGroup.manageControl.widthInputTitle": "最小宽度",
"controls.controlGroup.manageControl.dataSource.selectDataViewMessage": "请选择数据视图",
"controls.controlGroup.manageControl.displaySettings.titleInputTitle": "标签",
"controls.controlGroup.manageControl.displaySettings.widthInputTitle": "最小宽度",
"controls.controlGroup.management.addControl": "添加控件",
"controls.controlGroup.management.delete": "删除控件",
"controls.controlGroup.management.delete.cancel": "取消",
@ -472,7 +470,6 @@
"controls.optionsList.control.separator": ",",
"controls.optionsList.description": "添加用于选择字段值的菜单。",
"controls.optionsList.displayName": "选项列表",
"controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选",
"controls.optionsList.editor.runPastTimeout": "忽略超时以获取结果",
"controls.optionsList.editor.runPastTimeout.tooltip": "等待显示结果,直到列表完成。此设置用于大型数据集,但可能需要更长时间来填充结果。",
"controls.optionsList.popover.allOptionsTitle": "显示所有选项",
@ -485,7 +482,6 @@
"controls.optionsList.popover.includeLabel": "包括",
"controls.optionsList.popover.invalidSelectionScreenReaderText": "选择无效。",
"controls.optionsList.popover.loadingMore": "正在加载更多选项......",
"controls.optionsList.popover.searchPlaceholder": "搜索",
"controls.optionsList.popover.selectedOptionsTitle": "仅显示选定选项",
"controls.optionsList.popover.selectionsEmpty": "您未选择任何内容",
"controls.optionsList.popover.sortBy.alphabetical": "按字母顺序",