mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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 | |--------|-------| |  |  | 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 | |--------|-------| |  |  | ### `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 | |--------|-------| |  |  | ### 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: <br> - After this PR, the default title gets updated to the range slider field name: <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: <br> - After this PR, the title gets updated as expected: <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:
parent
67e60c0c1c
commit
8277665dbc
32 changed files with 1126 additions and 442 deletions
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 = () => ({});
|
||||
|
|
|
@ -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 }>;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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: () =>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -53,7 +53,6 @@ describe('options list cheap queries', () => {
|
|||
"suggestions": Object {
|
||||
"terms": Object {
|
||||
"field": "coolTestField.keyword",
|
||||
"include": ".*",
|
||||
"order": Object {
|
||||
"_count": "asc",
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[],
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "アルファベット順",
|
||||
|
|
|
@ -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": "按字母顺序",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue