mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Dashboard] [Controls] Allow options list suggestions to be sorted (#144867)
Closes https://github.com/elastic/kibana/issues/140174 Closes https://github.com/elastic/kibana/issues/145040 Closes https://github.com/elastic/kibana/issues/146086 ## Summary This PR adds two features to the options list control: 1. A button in the options list popover that gives users the ability to change how the suggestions are sorted <p align="center"> <img src="https://user-images.githubusercontent.com/8698078/203416853-58f9c909-8909-4902-adf3-59831018c96f.gif"/> </p> 2. A per-control setting that disables the ability to dynamically sort which, if set to `false`, presents the author with the ability to select one of the four sorting methods for that specific control to use <p align="center"> <img src="https://user-images.githubusercontent.com/8698078/203417193-cd35f264-8c29-4c80-b88b-15da25a1f56c.gif"/> </p> ### Design considerations @elastic/kibana-design As noted by Andrea when looking at the preliminary behaviour of this feature, the `"Show only selected"` toggle has increased in importance because of the new sorting mechanic - after all, when making selections and then changing the sort method, your selections can appear to be "lost" if you have enough unique values in the control's field. In the original designs, the `"Clear all selections"` button was **first** in the popover's action bar - however, I found that I kept accidentally clicking this in my testing when switching between searching, sorting, making selections, changing sorting, showing only selected options, etc. etc. I found that the following design felt a lot more natural for the placement of the `"Clear all selections"` button:  Note that, once https://github.com/elastic/kibana/issues/143585 is resolved, this will no longer be as much of a concern because we will be moving, at the very least, the `"Clear all selections"` to be a floating action. That being said, this new order for the actions is, in my opinion, a good compromise in the mean time. Very much open to feedback, though! ### Video https://user-images.githubusercontent.com/8698078/203422674-52aac87c-7295-4eb6-99a5-ee3ffba2756b.mov ### Testing Notes There are a few things to consider when testing: 1. Does the dynamic sorting give you expected results when sorting various field types? - Note that IP fields only support document count sorting, so ensure that "Alphabetical" sorting does not show up in the sorting list during either creation or as part of the popover sorting. 2. When setting the `"Allow suggestions to be sorted"` toggle to `false`, it should always default to `"Document count (descending)"` to prevent invalid sort selections. For example, consider the following: - Create an options list control on some keyword field - Set the sort to alphabetical (either ascending or descending) in the popover - Edit that control and change it to an IP field - Set `"Allow suggestions to be sorted"` to `false - The default sort should be `"Document count (descending)"` and **not** `"Alphabetical (descending/ascending)"`, since alphabetical sorting would be invalid in this case. **Flaky Test Runner** <a href="https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/1585"><img src="https://user-images.githubusercontent.com/8698078/203428246-13f5ff9a-df0c-4cd5-a4ee-cf7a98792362.png"/></a> ### Checklist Delete any items that are not applicable to this PR. - [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] 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)) <p><img src="https://user-images.githubusercontent.com/8698078/202545715-96daa0ab-8900-45cb-979f-20a83e622597.png"/></p> - [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
94b67781e8
commit
1ed31e1e76
20 changed files with 802 additions and 92 deletions
|
@ -8,6 +8,7 @@
|
|||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { omit, isEqual } from 'lodash';
|
||||
import { DEFAULT_SORT } from '../options_list/suggestions_sorting';
|
||||
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../options_list/types';
|
||||
|
||||
import { ControlPanelState } from './types';
|
||||
|
@ -32,7 +33,9 @@ export const ControlPanelDiffSystems: {
|
|||
}
|
||||
|
||||
const {
|
||||
sort: sortA,
|
||||
exclude: excludeA,
|
||||
hideSort: hideSortA,
|
||||
hideExists: hideExistsA,
|
||||
hideExclude: hideExcludeA,
|
||||
selectedOptions: selectedA,
|
||||
|
@ -42,7 +45,9 @@ export const ControlPanelDiffSystems: {
|
|||
...inputA
|
||||
}: Partial<OptionsListEmbeddableInput> = initialInput.explicitInput;
|
||||
const {
|
||||
sort: sortB,
|
||||
exclude: excludeB,
|
||||
hideSort: hideSortB,
|
||||
hideExists: hideExistsB,
|
||||
hideExclude: hideExcludeB,
|
||||
selectedOptions: selectedB,
|
||||
|
@ -54,11 +59,13 @@ export const ControlPanelDiffSystems: {
|
|||
|
||||
return (
|
||||
Boolean(excludeA) === Boolean(excludeB) &&
|
||||
Boolean(hideSortA) === Boolean(hideSortB) &&
|
||||
Boolean(hideExistsA) === Boolean(hideExistsB) &&
|
||||
Boolean(hideExcludeA) === Boolean(hideExcludeB) &&
|
||||
Boolean(singleSelectA) === Boolean(singleSelectB) &&
|
||||
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
|
||||
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
|
||||
deepEqual(sortA ?? DEFAULT_SORT, sortB ?? DEFAULT_SORT) &&
|
||||
isEqual(selectedA ?? [], selectedB ?? []) &&
|
||||
deepEqual(inputA, inputB)
|
||||
);
|
||||
|
|
|
@ -10,17 +10,20 @@ import { createReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public
|
|||
|
||||
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public';
|
||||
import { OptionsListComponentState, OptionsListReduxState } from '../../public/options_list/types';
|
||||
import { optionsListReducers } from '../../public/options_list/options_list_reducers';
|
||||
import {
|
||||
getDefaultComponentState,
|
||||
optionsListReducers,
|
||||
} from '../../public/options_list/options_list_reducers';
|
||||
import { ControlFactory, ControlOutput } from '../../public/types';
|
||||
import { OptionsListEmbeddableInput } from './types';
|
||||
|
||||
const mockOptionsListComponentState = {
|
||||
...getDefaultComponentState(),
|
||||
field: undefined,
|
||||
totalCardinality: 0,
|
||||
availableOptions: ['woof', 'bark', 'meow', 'quack', 'moo'],
|
||||
invalidSelections: [],
|
||||
validSelections: [],
|
||||
searchString: { value: '', valid: true },
|
||||
} as OptionsListComponentState;
|
||||
|
||||
const mockOptionsListEmbeddableInput = {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Direction } from '@elastic/eui';
|
||||
|
||||
export type OptionsListSortBy = '_count' | '_key';
|
||||
|
||||
export const DEFAULT_SORT: SortingType = { by: '_count', direction: 'desc' };
|
||||
|
||||
export const sortDirections: Readonly<Direction[]> = ['asc', 'desc'] as const;
|
||||
export type SortDirection = typeof sortDirections[number];
|
||||
export interface SortingType {
|
||||
by: OptionsListSortBy;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
export const getCompatibleSortingTypes = (type?: string): OptionsListSortBy[] => {
|
||||
switch (type) {
|
||||
case 'ip': {
|
||||
return ['_count'];
|
||||
}
|
||||
default: {
|
||||
return ['_count', '_key'];
|
||||
}
|
||||
}
|
||||
};
|
|
@ -6,9 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query';
|
||||
import { FieldSpec, DataView, RuntimeFieldSpec } from '@kbn/data-views-plugin/common';
|
||||
import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query';
|
||||
|
||||
import { SortingType } from './suggestions_sorting';
|
||||
import { DataControlInput } from '../types';
|
||||
|
||||
export const OPTIONS_LIST_CONTROL = 'optionsListControl';
|
||||
|
@ -20,6 +21,8 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
|
|||
singleSelect?: boolean;
|
||||
hideExclude?: boolean;
|
||||
hideExists?: boolean;
|
||||
hideSort?: boolean;
|
||||
sort?: SortingType;
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
|
@ -65,5 +68,6 @@ export interface OptionsListRequestBody {
|
|||
textFieldName?: string;
|
||||
searchString?: string;
|
||||
fieldSpec?: FieldSpec;
|
||||
sort?: SortingType;
|
||||
fieldName: string;
|
||||
}
|
||||
|
|
|
@ -243,34 +243,41 @@ export const ControlEditor = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{updateGrow ? (
|
||||
<EuiFormRow>
|
||||
<EuiSwitch
|
||||
label={ControlGroupStrings.manageControl.getGrowSwitchTitle()}
|
||||
<>
|
||||
<EuiButtonGroup
|
||||
color="primary"
|
||||
checked={currentGrow}
|
||||
onChange={() => {
|
||||
setCurrentGrow(!currentGrow);
|
||||
updateGrow(!currentGrow);
|
||||
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
|
||||
options={CONTROL_WIDTH_OPTIONS}
|
||||
idSelected={currentWidth}
|
||||
onChange={(newWidth: string) => {
|
||||
setCurrentWidth(newWidth as ControlWidth);
|
||||
updateWidth(newWidth as ControlWidth);
|
||||
}}
|
||||
data-test-subj="control-editor-grow-switch"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
{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>
|
||||
{CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && (
|
||||
<EuiFormRow label={ControlGroupStrings.manageControl.getControlSettingsTitle()}>
|
||||
<CustomSettings onChange={onTypeEditorChange} initialInput={embeddable?.getInput()} />
|
||||
<CustomSettings
|
||||
onChange={onTypeEditorChange}
|
||||
initialInput={embeddable?.getInput()}
|
||||
fieldType={fieldRegistry[selectedField].field.type}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{removeControl && (
|
||||
|
|
|
@ -87,3 +87,11 @@
|
|||
.optionsList--filterGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.optionsList--hiddenEditorForm {
|
||||
margin-left: $euiSizeXXL + $euiSizeM;
|
||||
}
|
||||
|
||||
.optionsList--sortPopover {
|
||||
width: $euiSizeXL * 7;
|
||||
}
|
||||
|
|
|
@ -6,25 +6,42 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIconTip,
|
||||
EuiSuperSelectOption,
|
||||
EuiSpacer,
|
||||
EuiSuperSelect,
|
||||
EuiSwitch,
|
||||
EuiSwitchEvent,
|
||||
EuiButtonGroup,
|
||||
toSentenceCase,
|
||||
Direction,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import {
|
||||
getCompatibleSortingTypes,
|
||||
sortDirections,
|
||||
DEFAULT_SORT,
|
||||
OptionsListSortBy,
|
||||
} from '../../../common/options_list/suggestions_sorting';
|
||||
import { OptionsListStrings } from './options_list_strings';
|
||||
import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
|
||||
|
||||
interface OptionsListEditorState {
|
||||
singleSelect?: boolean;
|
||||
sortDirection: Direction;
|
||||
runPastTimeout?: boolean;
|
||||
singleSelect?: boolean;
|
||||
hideExclude?: boolean;
|
||||
hideExists?: boolean;
|
||||
hideSort?: boolean;
|
||||
sortBy: OptionsListSortBy;
|
||||
}
|
||||
|
||||
interface SwitchProps {
|
||||
|
@ -32,17 +49,53 @@ interface SwitchProps {
|
|||
onChange: (event: EuiSwitchEvent) => void;
|
||||
}
|
||||
|
||||
type SortItem = EuiSuperSelectOption<OptionsListSortBy>;
|
||||
|
||||
export const OptionsListEditorOptions = ({
|
||||
initialInput,
|
||||
onChange,
|
||||
fieldType,
|
||||
}: ControlEditorProps<OptionsListEmbeddableInput>) => {
|
||||
const [state, setState] = useState<OptionsListEditorState>({
|
||||
singleSelect: initialInput?.singleSelect,
|
||||
sortDirection: initialInput?.sort?.direction ?? DEFAULT_SORT.direction,
|
||||
sortBy: initialInput?.sort?.by ?? DEFAULT_SORT.by,
|
||||
runPastTimeout: initialInput?.runPastTimeout,
|
||||
singleSelect: initialInput?.singleSelect,
|
||||
hideExclude: initialInput?.hideExclude,
|
||||
hideExists: initialInput?.hideExists,
|
||||
hideSort: initialInput?.hideSort,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// when field type changes, ensure that the selected sort type is still valid
|
||||
if (!getCompatibleSortingTypes(fieldType).includes(state.sortBy)) {
|
||||
onChange({ sort: DEFAULT_SORT });
|
||||
setState((s) => ({ ...s, sortBy: DEFAULT_SORT.by, sortDirection: DEFAULT_SORT.direction }));
|
||||
}
|
||||
}, [fieldType, onChange, state.sortBy]);
|
||||
|
||||
const sortByOptions: SortItem[] = useMemo(() => {
|
||||
return getCompatibleSortingTypes(fieldType).map((key: OptionsListSortBy) => {
|
||||
return {
|
||||
value: key,
|
||||
inputDisplay: OptionsListStrings.editorAndPopover.sortBy[key].getSortByLabel(),
|
||||
'data-test-subj': `optionsListEditor__sortBy_${key}`,
|
||||
};
|
||||
});
|
||||
}, [fieldType]);
|
||||
|
||||
const sortOrderOptions = useMemo(() => {
|
||||
return sortDirections.map((key) => {
|
||||
return {
|
||||
id: key,
|
||||
value: key,
|
||||
iconType: `sort${toSentenceCase(key)}ending`,
|
||||
'data-test-subj': `optionsListEditor__sortOrder_${key}`,
|
||||
label: OptionsListStrings.editorAndPopover.sortOrder[key].getSortOrderLabel(),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const SwitchWithTooltip = ({
|
||||
switchProps,
|
||||
label,
|
||||
|
@ -77,6 +130,7 @@ export const OptionsListEditorOptions = ({
|
|||
onChange({ singleSelect: !state.singleSelect });
|
||||
setState((s) => ({ ...s, singleSelect: !s.singleSelect }));
|
||||
}}
|
||||
data-test-subj={'optionsListControl__allowMultipleAdditionalSetting'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
|
@ -88,6 +142,7 @@ export const OptionsListEditorOptions = ({
|
|||
setState((s) => ({ ...s, hideExclude: !s.hideExclude }));
|
||||
if (initialInput?.exclude) onChange({ exclude: false });
|
||||
}}
|
||||
data-test-subj={'optionsListControl__hideExcludeAdditionalSetting'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
|
@ -102,8 +157,74 @@ export const OptionsListEditorOptions = ({
|
|||
if (initialInput?.existsSelected) onChange({ existsSelected: false });
|
||||
},
|
||||
}}
|
||||
data-test-subj={'optionsListControl__hideExistsAdditionalSetting'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
<>
|
||||
<EuiSwitch
|
||||
label={OptionsListStrings.editor.getHideSortingTitle()}
|
||||
checked={!state.hideSort}
|
||||
onChange={() => {
|
||||
onChange({ hideSort: !state.hideSort });
|
||||
setState((s) => ({ ...s, hideSort: !s.hideSort }));
|
||||
}}
|
||||
data-test-subj={'optionsListControl__hideSortAdditionalSetting'}
|
||||
/>
|
||||
{state.hideSort && (
|
||||
<EuiForm className="optionsList--hiddenEditorForm">
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFormRow
|
||||
display={'rowCompressed'}
|
||||
label={OptionsListStrings.editor.getSuggestionsSortingTitle()}
|
||||
>
|
||||
<EuiButtonGroup
|
||||
buttonSize="compressed"
|
||||
options={sortOrderOptions}
|
||||
idSelected={state.sortDirection}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
sort: {
|
||||
direction: value as Direction,
|
||||
by: state.sortBy,
|
||||
},
|
||||
});
|
||||
setState((s) => ({ ...s, sortDirection: value as Direction }));
|
||||
}}
|
||||
legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
display={'rowCompressed'}
|
||||
css={css`
|
||||
margin-top: 8px !important;
|
||||
`}
|
||||
hasEmptyLabelSpace={false}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
sort: {
|
||||
direction: state.sortDirection,
|
||||
by: value,
|
||||
},
|
||||
});
|
||||
setState((s) => ({ ...s, sortBy: value }));
|
||||
}}
|
||||
options={sortByOptions}
|
||||
valueOfSelected={state.sortBy}
|
||||
data-test-subj={'optionsListControl__chooseSortBy'}
|
||||
compressed={true}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
</EuiForm>
|
||||
)}
|
||||
</>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
<SwitchWithTooltip
|
||||
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
|
||||
|
@ -115,6 +236,7 @@ export const OptionsListEditorOptions = ({
|
|||
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
|
||||
},
|
||||
}}
|
||||
data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
|
|
|
@ -14,8 +14,9 @@ import { findTestSubject } from '@elastic/eui/lib/test';
|
|||
|
||||
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
|
||||
import { OptionsListComponentState, OptionsListReduxState } from '../types';
|
||||
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
|
||||
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
|
||||
import { OptionsListField } from '../../../common/options_list/types';
|
||||
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
|
||||
|
||||
describe('Options list popover', () => {
|
||||
const defaultProps = {
|
||||
|
@ -100,6 +101,23 @@ describe('Options list popover', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('disable search and sort when show only selected toggle is true', async () => {
|
||||
const selections = ['woof', 'bark'];
|
||||
const popover = await mountComponent({
|
||||
explicitInput: { selectedOptions: selections },
|
||||
});
|
||||
let searchBox = findTestSubject(popover, 'optionsList-control-search-input');
|
||||
let sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
|
||||
expect(searchBox.prop('disabled')).toBeFalsy();
|
||||
expect(sortButton.prop('disabled')).toBeFalsy();
|
||||
|
||||
clickShowOnlySelections(popover);
|
||||
searchBox = findTestSubject(popover, 'optionsList-control-search-input');
|
||||
sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
|
||||
expect(searchBox.prop('disabled')).toBe(true);
|
||||
expect(sortButton.prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
test('should default to exclude = false', async () => {
|
||||
const popover = await mountComponent();
|
||||
const includeButton = findTestSubject(popover, 'optionsList__includeResults');
|
||||
|
@ -169,4 +187,87 @@ describe('Options list popover', () => {
|
|||
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
|
||||
expect(availableOptionsDiv.children().at(0).text()).toBe('Exists');
|
||||
});
|
||||
|
||||
test('when sorting suggestions, show both sorting types for keyword field', async () => {
|
||||
const popover = await mountComponent({
|
||||
componentState: {
|
||||
field: { name: 'Test keyword field', type: 'keyword' } as OptionsListField,
|
||||
},
|
||||
});
|
||||
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
|
||||
sortButton.simulate('click');
|
||||
|
||||
const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions');
|
||||
const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim());
|
||||
expect(optionsText).toEqual(['By document count - Checked option.', 'Alphabetically']);
|
||||
});
|
||||
|
||||
test('sorting popover selects appropriate sorting type on load', async () => {
|
||||
const popover = await mountComponent({
|
||||
explicitInput: { sort: { by: '_key', direction: 'asc' } },
|
||||
componentState: {
|
||||
field: { name: 'Test keyword field', type: 'keyword' } as OptionsListField,
|
||||
},
|
||||
});
|
||||
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
|
||||
sortButton.simulate('click');
|
||||
|
||||
const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions');
|
||||
const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim());
|
||||
expect(optionsText).toEqual(['By document count', 'Alphabetically - Checked option.']);
|
||||
|
||||
const ascendingButton = findTestSubject(popover, 'optionsList__sortOrder_asc').instance();
|
||||
expect(ascendingButton).toHaveClass('euiButtonGroupButton-isSelected');
|
||||
const descendingButton = findTestSubject(popover, 'optionsList__sortOrder_desc').instance();
|
||||
expect(descendingButton).not.toHaveClass('euiButtonGroupButton-isSelected');
|
||||
});
|
||||
|
||||
test('when sorting suggestions, only show document count sorting for IP fields', async () => {
|
||||
const popover = await mountComponent({
|
||||
componentState: { field: { name: 'Test IP field', type: 'ip' } as OptionsListField },
|
||||
});
|
||||
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
|
||||
sortButton.simulate('click');
|
||||
|
||||
const sortingOptionsDiv = findTestSubject(popover, 'optionsListControl__sortingOptions');
|
||||
const optionsText = sortingOptionsDiv.find('ul li').map((element) => element.text().trim());
|
||||
expect(optionsText).toEqual(['By document count - Checked option.']);
|
||||
});
|
||||
|
||||
describe('Test advanced settings', () => {
|
||||
const ensureComponentIsHidden = async ({
|
||||
explicitInput,
|
||||
testSubject,
|
||||
}: {
|
||||
explicitInput: Partial<OptionsListEmbeddableInput>;
|
||||
testSubject: string;
|
||||
}) => {
|
||||
const popover = await mountComponent({
|
||||
explicitInput,
|
||||
});
|
||||
const test = findTestSubject(popover, testSubject);
|
||||
expect(test.exists()).toBeFalsy();
|
||||
};
|
||||
|
||||
test('can hide exists option', async () => {
|
||||
ensureComponentIsHidden({
|
||||
explicitInput: { hideExists: true },
|
||||
testSubject: 'optionsList-control-selection-exists',
|
||||
});
|
||||
});
|
||||
|
||||
test('can hide include/exclude toggle', async () => {
|
||||
ensureComponentIsHidden({
|
||||
explicitInput: { hideExclude: true },
|
||||
testSubject: 'optionsList__includeExcludeButtonGroup',
|
||||
});
|
||||
});
|
||||
|
||||
test('can hide sorting button', async () => {
|
||||
ensureComponentIsHidden({
|
||||
explicitInput: { hideSort: true },
|
||||
testSubject: 'optionsListControl__sortingOptionsButton',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,6 +22,7 @@ import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'
|
|||
import { OptionsListReduxState } from '../types';
|
||||
import { OptionsListStrings } from './options_list_strings';
|
||||
import { optionsListReducers } from '../options_list_reducers';
|
||||
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
|
||||
|
||||
interface OptionsListPopoverProps {
|
||||
showOnlySelected: boolean;
|
||||
|
@ -31,8 +32,8 @@ interface OptionsListPopoverProps {
|
|||
|
||||
export const OptionsListPopoverActionBar = ({
|
||||
showOnlySelected,
|
||||
setShowOnlySelected,
|
||||
updateSearchString,
|
||||
setShowOnlySelected,
|
||||
}: OptionsListPopoverProps) => {
|
||||
// Redux embeddable container Context
|
||||
const {
|
||||
|
@ -47,6 +48,8 @@ export const OptionsListPopoverActionBar = ({
|
|||
const totalCardinality = select((state) => state.componentState.totalCardinality);
|
||||
const searchString = select((state) => state.componentState.searchString);
|
||||
|
||||
const hideSort = select((state) => state.explicitInput.hideSort);
|
||||
|
||||
return (
|
||||
<div className="optionsList__actions">
|
||||
<EuiFormRow fullWidth>
|
||||
|
@ -87,21 +90,11 @@ export const OptionsListPopoverActionBar = ({
|
|||
</EuiToolTip>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
color="danger"
|
||||
iconType="eraser"
|
||||
data-test-subj="optionsList-control-clear-all-selections"
|
||||
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
onClick={() => dispatch(clearSelections({}))}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
{!hideSort && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<OptionsListPopoverSortingButton showOnlySelected={showOnlySelected} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
|
@ -117,9 +110,24 @@ export const OptionsListPopoverActionBar = ({
|
|||
aria-pressed={showOnlySelected}
|
||||
color={showOnlySelected ? 'primary' : 'text'}
|
||||
display={showOnlySelected ? 'base' : 'empty'}
|
||||
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
data-test-subj="optionsList-control-show-only-selected"
|
||||
onClick={() => setShowOnlySelected(!showOnlySelected)}
|
||||
data-test-subj="optionsList-control-show-only-selected"
|
||||
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
color="danger"
|
||||
iconType="eraser"
|
||||
onClick={() => dispatch(clearSelections({}))}
|
||||
data-test-subj="optionsList-control-clear-all-selections"
|
||||
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiButtonGroupOptionProps,
|
||||
EuiSelectableOption,
|
||||
EuiPopoverTitle,
|
||||
EuiButtonGroup,
|
||||
toSentenceCase,
|
||||
EuiButtonIcon,
|
||||
EuiSelectable,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiToolTip,
|
||||
EuiPopover,
|
||||
Direction,
|
||||
} from '@elastic/eui';
|
||||
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import {
|
||||
getCompatibleSortingTypes,
|
||||
DEFAULT_SORT,
|
||||
sortDirections,
|
||||
OptionsListSortBy,
|
||||
} from '../../../common/options_list/suggestions_sorting';
|
||||
import { OptionsListReduxState } from '../types';
|
||||
import { OptionsListStrings } from './options_list_strings';
|
||||
import { optionsListReducers } from '../options_list_reducers';
|
||||
|
||||
interface OptionsListSortingPopoverProps {
|
||||
showOnlySelected: boolean;
|
||||
}
|
||||
type SortByItem = EuiSelectableOption & {
|
||||
data: { sortBy: OptionsListSortBy };
|
||||
};
|
||||
type SortOrderItem = EuiButtonGroupOptionProps & {
|
||||
value: Direction;
|
||||
};
|
||||
|
||||
export const OptionsListPopoverSortingButton = ({
|
||||
showOnlySelected,
|
||||
}: OptionsListSortingPopoverProps) => {
|
||||
// Redux embeddable container Context
|
||||
const {
|
||||
useEmbeddableDispatch,
|
||||
useEmbeddableSelector: select,
|
||||
actions: { setSort },
|
||||
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
|
||||
const dispatch = useEmbeddableDispatch();
|
||||
|
||||
// Select current state from Redux using multiple selectors to avoid rerenders.
|
||||
const field = select((state) => state.componentState.field);
|
||||
const sort = select((state) => state.explicitInput.sort ?? DEFAULT_SORT);
|
||||
|
||||
const [isSortingPopoverOpen, setIsSortingPopoverOpen] = useState(false);
|
||||
|
||||
const [sortByOptions, setSortByOptions] = useState<SortByItem[]>(() => {
|
||||
return getCompatibleSortingTypes(field?.type).map((key) => {
|
||||
return {
|
||||
onFocusBadge: false,
|
||||
data: { sortBy: key },
|
||||
checked: key === sort.by ? 'on' : undefined,
|
||||
'data-test-subj': `optionsList__sortBy_${key}`,
|
||||
label: OptionsListStrings.editorAndPopover.sortBy[key].getSortByLabel(),
|
||||
} as SortByItem;
|
||||
});
|
||||
});
|
||||
|
||||
const sortOrderOptions = useMemo(
|
||||
() =>
|
||||
sortDirections.map((key) => {
|
||||
return {
|
||||
id: key,
|
||||
iconType: `sort${toSentenceCase(key)}ending`,
|
||||
'data-test-subj': `optionsList__sortOrder_${key}`,
|
||||
label: OptionsListStrings.editorAndPopover.sortOrder[key].getSortOrderLabel(),
|
||||
} as SortOrderItem;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const onSortByChange = (updatedOptions: SortByItem[]) => {
|
||||
setSortByOptions(updatedOptions);
|
||||
const selectedOption = updatedOptions.find(({ checked }) => checked === 'on');
|
||||
if (selectedOption) {
|
||||
dispatch(setSort({ by: selectedOption.data.sortBy }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={
|
||||
showOnlySelected
|
||||
? OptionsListStrings.popover.getSortDisabledTooltip()
|
||||
: OptionsListStrings.popover.getSortPopoverDescription()
|
||||
}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
iconType="sortable"
|
||||
disabled={showOnlySelected}
|
||||
data-test-subj="optionsListControl__sortingOptionsButton"
|
||||
onClick={() => setIsSortingPopoverOpen(!isSortingPopoverOpen)}
|
||||
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
}
|
||||
panelPaddingSize="none"
|
||||
isOpen={isSortingPopoverOpen}
|
||||
aria-labelledby="optionsList_sortingOptions"
|
||||
closePopover={() => setIsSortingPopoverOpen(false)}
|
||||
panelClassName={'optionsList--sortPopover'}
|
||||
>
|
||||
<span data-test-subj="optionsListControl__sortingOptionsPopover">
|
||||
<EuiPopoverTitle paddingSize="s">
|
||||
<EuiFlexGroup alignItems="center" responsive={false}>
|
||||
<EuiFlexItem>{OptionsListStrings.popover.getSortPopoverTitle()}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonGroup
|
||||
isIconOnly
|
||||
buttonSize="compressed"
|
||||
options={sortOrderOptions}
|
||||
idSelected={sort.direction}
|
||||
legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()}
|
||||
onChange={(value) => dispatch(setSort({ direction: value as Direction }))}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopoverTitle>
|
||||
<EuiSelectable
|
||||
options={sortByOptions}
|
||||
singleSelection="always"
|
||||
onChange={onSortByChange}
|
||||
id="optionsList_sortingOptions"
|
||||
listProps={{ bordered: false }}
|
||||
data-test-subj="optionsListControl__sortingOptions"
|
||||
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
|
||||
>
|
||||
{(list) => list}
|
||||
</EuiSelectable>
|
||||
</span>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -54,6 +54,14 @@ export const OptionsListStrings = {
|
|||
defaultMessage:
|
||||
'Allows you to create an exists query, which returns all documents that contain an indexed value for the field.',
|
||||
}),
|
||||
getHideSortingTitle: () =>
|
||||
i18n.translate('controls.optionsList.editor.hideSort', {
|
||||
defaultMessage: 'Allow dynamic sorting of suggestions',
|
||||
}),
|
||||
getSuggestionsSortingTitle: () =>
|
||||
i18n.translate('controls.optionsList.editor.suggestionsSorting', {
|
||||
defaultMessage: 'Default sort order',
|
||||
}),
|
||||
},
|
||||
popover: {
|
||||
getAriaLabel: (fieldName: string) =>
|
||||
|
@ -129,6 +137,18 @@ export const OptionsListStrings = {
|
|||
i18n.translate('controls.optionsList.popover.excludeOptionsLegend', {
|
||||
defaultMessage: 'Include or exclude selections',
|
||||
}),
|
||||
getSortPopoverTitle: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortTitle', {
|
||||
defaultMessage: 'Sort',
|
||||
}),
|
||||
getSortPopoverDescription: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortDescription', {
|
||||
defaultMessage: 'Define the sort order',
|
||||
}),
|
||||
getSortDisabledTooltip: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortDisabledTooltip', {
|
||||
defaultMessage: 'Ignore sorting when “Show only selected” is true.',
|
||||
}),
|
||||
},
|
||||
controlAndPopover: {
|
||||
getExists: (negate: number = +false) =>
|
||||
|
@ -137,4 +157,38 @@ export const OptionsListStrings = {
|
|||
values: { negate },
|
||||
}),
|
||||
},
|
||||
editorAndPopover: {
|
||||
getSortDirectionLegend: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortDirections', {
|
||||
defaultMessage: 'Sort directions',
|
||||
}),
|
||||
sortBy: {
|
||||
_count: {
|
||||
getSortByLabel: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortBy.docCount', {
|
||||
defaultMessage: 'By document count',
|
||||
}),
|
||||
},
|
||||
_key: {
|
||||
getSortByLabel: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortBy.alphabetical', {
|
||||
defaultMessage: 'Alphabetically',
|
||||
}),
|
||||
},
|
||||
},
|
||||
sortOrder: {
|
||||
asc: {
|
||||
getSortOrderLabel: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortOrder.asc', {
|
||||
defaultMessage: 'Ascending',
|
||||
}),
|
||||
},
|
||||
desc: {
|
||||
getSortOrderLabel: () =>
|
||||
i18n.translate('controls.optionsList.popover.sortOrder.desc', {
|
||||
defaultMessage: 'Descending',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -137,6 +137,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
|
|||
exclude: newInput.exclude,
|
||||
filters: newInput.filters,
|
||||
query: newInput.query,
|
||||
sort: newInput.sort,
|
||||
})),
|
||||
distinctUntilChanged(diffDataFetchProps)
|
||||
);
|
||||
|
@ -284,7 +285,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
|
|||
|
||||
const {
|
||||
componentState: { searchString },
|
||||
explicitInput: { selectedOptions, runPastTimeout, existsSelected },
|
||||
explicitInput: { selectedOptions, runPastTimeout, existsSelected, sort },
|
||||
} = getState();
|
||||
dispatch(setLoading(true));
|
||||
if (searchString.valid) {
|
||||
|
@ -296,7 +297,6 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
|
|||
timeRange: globalTimeRange,
|
||||
timeslice,
|
||||
} = this.getInput();
|
||||
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
const timeRange =
|
||||
|
@ -310,6 +310,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
|
|||
const { suggestions, invalidSelections, totalCardinality } =
|
||||
await this.optionsListService.runOptionsListRequest(
|
||||
{
|
||||
sort,
|
||||
field,
|
||||
query,
|
||||
filters,
|
||||
|
|
|
@ -13,6 +13,7 @@ import { Filter } from '@kbn/es-query';
|
|||
import { OptionsListReduxState, OptionsListComponentState } from './types';
|
||||
import { OptionsListField } from '../../common/options_list/types';
|
||||
import { getIpRangeQuery } from '../../common/options_list/ip_search';
|
||||
import { DEFAULT_SORT, SortingType } from '../../common/options_list/suggestions_sorting';
|
||||
|
||||
export const getDefaultComponentState = (): OptionsListReduxState['componentState'] => ({
|
||||
searchString: { value: '', valid: true },
|
||||
|
@ -51,6 +52,12 @@ export const optionsListReducers = {
|
|||
state.componentState.searchString.valid = getIpRangeQuery(action.payload).validSearch;
|
||||
}
|
||||
},
|
||||
setSort: (
|
||||
state: WritableDraft<OptionsListReduxState>,
|
||||
action: PayloadAction<Partial<SortingType>>
|
||||
) => {
|
||||
state.explicitInput.sort = { ...(state.explicitInput.sort ?? DEFAULT_SORT), ...action.payload };
|
||||
},
|
||||
selectExists: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
|
||||
if (action.payload) {
|
||||
state.explicitInput.existsSelected = true;
|
||||
|
|
|
@ -38,6 +38,7 @@ class OptionsListService implements ControlsOptionsListService {
|
|||
|
||||
private optionsListCacheResolver = (request: OptionsListRequest) => {
|
||||
const {
|
||||
sort,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
|
@ -53,6 +54,7 @@ class OptionsListService implements ControlsOptionsListService {
|
|||
selectedOptions?.join(','),
|
||||
JSON.stringify(filters),
|
||||
JSON.stringify(query),
|
||||
JSON.stringify(sort),
|
||||
runPastTimeout,
|
||||
dataViewTitle,
|
||||
searchString,
|
||||
|
|
|
@ -57,6 +57,7 @@ export interface IEditableControlFactory<T extends ControlInput = ControlInput>
|
|||
|
||||
export interface ControlEditorProps<T extends ControlInput = ControlInput> {
|
||||
initialInput?: Partial<T>;
|
||||
fieldType: string;
|
||||
onChange: (partial: Partial<T>) => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -119,6 +119,9 @@ describe('options list queries', () => {
|
|||
"keywordSuggestions": Object {
|
||||
"terms": Object {
|
||||
"field": "coolTestField.keyword",
|
||||
"order": Object {
|
||||
"_count": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -137,6 +140,7 @@ describe('options list queries', () => {
|
|||
fieldName: 'coolTestField.keyword',
|
||||
textFieldName: 'coolTestField',
|
||||
fieldSpec: { aggregatable: true } as unknown as FieldSpec,
|
||||
sort: { by: '_count', direction: 'asc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -146,6 +150,9 @@ describe('options list queries', () => {
|
|||
"execution_hint": "map",
|
||||
"field": "coolTestField.keyword",
|
||||
"include": ".*",
|
||||
"order": Object {
|
||||
"_count": "asc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
}
|
||||
|
@ -157,6 +164,7 @@ describe('options list queries', () => {
|
|||
const optionsListRequestBodyMock: OptionsListRequestBody = {
|
||||
fieldName: 'coolean',
|
||||
fieldSpec: { type: 'boolean' } as unknown as FieldSpec,
|
||||
sort: { by: '_key', direction: 'desc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -165,6 +173,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "coolean",
|
||||
"order": Object {
|
||||
"_key": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
}
|
||||
|
@ -176,6 +187,7 @@ describe('options list queries', () => {
|
|||
fieldName: 'coolNestedField',
|
||||
searchString: 'cooool',
|
||||
fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec,
|
||||
sort: { by: '_key', direction: 'asc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -187,6 +199,9 @@ describe('options list queries', () => {
|
|||
"execution_hint": "map",
|
||||
"field": "coolNestedField",
|
||||
"include": "cooool.*",
|
||||
"order": Object {
|
||||
"_key": "asc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -212,6 +227,9 @@ describe('options list queries', () => {
|
|||
"execution_hint": "map",
|
||||
"field": "coolTestField.keyword",
|
||||
"include": "cooool.*",
|
||||
"order": Object {
|
||||
"_count": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
}
|
||||
|
@ -223,6 +241,7 @@ describe('options list queries', () => {
|
|||
const optionsListRequestBodyMock: OptionsListRequestBody = {
|
||||
fieldName: 'clientip',
|
||||
fieldSpec: { type: 'ip' } as unknown as FieldSpec,
|
||||
sort: { by: '_count', direction: 'asc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -233,6 +252,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "clientip",
|
||||
"order": Object {
|
||||
"_count": "asc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -257,6 +279,7 @@ describe('options list queries', () => {
|
|||
fieldName: 'clientip',
|
||||
fieldSpec: { type: 'ip' } as unknown as FieldSpec,
|
||||
searchString: '41.77.243.255',
|
||||
sort: { by: '_key', direction: 'desc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -267,6 +290,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "clientip",
|
||||
"order": Object {
|
||||
"_key": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -290,6 +316,7 @@ describe('options list queries', () => {
|
|||
fieldName: 'clientip',
|
||||
fieldSpec: { type: 'ip' } as unknown as FieldSpec,
|
||||
searchString: 'f688:fb50:6433:bba2:604:f2c:194a:d3c5',
|
||||
sort: { by: '_key', direction: 'asc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -300,6 +327,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "clientip",
|
||||
"order": Object {
|
||||
"_key": "asc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -333,6 +363,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "clientip",
|
||||
"order": Object {
|
||||
"_count": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
@ -357,6 +390,7 @@ describe('options list queries', () => {
|
|||
fieldName: 'clientip',
|
||||
fieldSpec: { type: 'ip' } as unknown as FieldSpec,
|
||||
searchString: 'cdb6:',
|
||||
sort: { by: '_count', direction: 'desc' },
|
||||
};
|
||||
const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock);
|
||||
expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock))
|
||||
|
@ -367,6 +401,9 @@ describe('options list queries', () => {
|
|||
"terms": Object {
|
||||
"execution_hint": "map",
|
||||
"field": "clientip",
|
||||
"order": Object {
|
||||
"_count": "desc",
|
||||
},
|
||||
"shard_size": 10,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -10,6 +10,7 @@ import { get, isEmpty } from 'lodash';
|
|||
import { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common';
|
||||
|
||||
import { DEFAULT_SORT, SortingType } from '../../common/options_list/suggestions_sorting';
|
||||
import { OptionsListRequestBody } from '../../common/options_list/types';
|
||||
import { getIpRangeQuery, type IpRangeQuery } from '../../common/options_list/ip_search';
|
||||
export interface OptionsListAggregationBuilder {
|
||||
|
@ -22,6 +23,10 @@ interface EsBucket {
|
|||
doc_count: number;
|
||||
}
|
||||
|
||||
const getSortType = (sort?: SortingType) => {
|
||||
return sort ? { [sort.by]: sort.direction } : { [DEFAULT_SORT.by]: DEFAULT_SORT.direction };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation aggregations
|
||||
*/
|
||||
|
@ -96,12 +101,13 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
* the "Keyword only" query / parser should be used when the options list is built on a field which has only keyword mappings.
|
||||
*/
|
||||
keywordOnly: {
|
||||
buildAggregation: ({ fieldName, searchString }: OptionsListRequestBody) => ({
|
||||
buildAggregation: ({ fieldName, searchString, sort }: OptionsListRequestBody) => ({
|
||||
terms: {
|
||||
field: fieldName,
|
||||
include: `${getEscapedQuery(searchString)}.*`,
|
||||
execution_hint: 'map',
|
||||
shard_size: 10,
|
||||
order: getSortType(sort),
|
||||
},
|
||||
}),
|
||||
parse: (rawEsResult) =>
|
||||
|
@ -119,7 +125,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
// if there is no textFieldName specified, or if there is no search string yet fall back to keywordOnly
|
||||
return suggestionAggSubtypes.keywordOnly.buildAggregation(req);
|
||||
}
|
||||
const { fieldName, searchString, textFieldName } = req;
|
||||
const { fieldName, searchString, textFieldName, sort } = req;
|
||||
return {
|
||||
filter: {
|
||||
match_phrase_prefix: {
|
||||
|
@ -131,6 +137,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
terms: {
|
||||
field: fieldName,
|
||||
shard_size: 10,
|
||||
order: getSortType(sort),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -146,11 +153,12 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
* the "Boolean" query / parser should be used when the options list is built on a field of type boolean. The query is slightly different than a keyword query.
|
||||
*/
|
||||
boolean: {
|
||||
buildAggregation: ({ fieldName }: OptionsListRequestBody) => ({
|
||||
buildAggregation: ({ fieldName, sort }: OptionsListRequestBody) => ({
|
||||
terms: {
|
||||
field: fieldName,
|
||||
execution_hint: 'map',
|
||||
shard_size: 10,
|
||||
order: getSortType(sort),
|
||||
},
|
||||
}),
|
||||
parse: (rawEsResult) =>
|
||||
|
@ -163,7 +171,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
* the "IP" query / parser should be used when the options list is built on a field of type IP.
|
||||
*/
|
||||
ip: {
|
||||
buildAggregation: ({ fieldName, searchString }: OptionsListRequestBody) => {
|
||||
buildAggregation: ({ fieldName, searchString, sort }: OptionsListRequestBody) => {
|
||||
let ipRangeQuery: IpRangeQuery = {
|
||||
validSearch: true,
|
||||
rangeQuery: [
|
||||
|
@ -196,6 +204,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
field: fieldName,
|
||||
execution_hint: 'map',
|
||||
shard_size: 10,
|
||||
order: getSortType(sort),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -223,7 +232,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
*/
|
||||
subtypeNested: {
|
||||
buildAggregation: (req: OptionsListRequestBody) => {
|
||||
const { fieldSpec, fieldName, searchString } = req;
|
||||
const { fieldSpec, fieldName, searchString, sort } = req;
|
||||
const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec);
|
||||
if (!subTypeNested) {
|
||||
// if this field is not subtype nested, fall back to keywordOnly
|
||||
|
@ -240,6 +249,7 @@ const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } =
|
|||
include: `${getEscapedQuery(searchString)}.*`,
|
||||
execution_hint: 'map',
|
||||
shard_size: 10,
|
||||
order: getSortType(sort),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -40,6 +40,7 @@ export const setupOptionsListSuggestionsRoute = (
|
|||
body: schema.object(
|
||||
{
|
||||
fieldName: schema.string(),
|
||||
sort: schema.maybe(schema.any()),
|
||||
filters: schema.maybe(schema.any()),
|
||||
fieldSpec: schema.maybe(schema.any()),
|
||||
searchString: schema.maybe(schema.string()),
|
||||
|
|
|
@ -36,6 +36,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const DASHBOARD_NAME = 'Test Options List Control';
|
||||
|
||||
describe('Dashboard options list integration', () => {
|
||||
let controlId: string;
|
||||
|
||||
const animalSoundAvailableOptions = [
|
||||
'hiss',
|
||||
'ruff',
|
||||
'bark',
|
||||
'grrr',
|
||||
'meow',
|
||||
'growl',
|
||||
'grr',
|
||||
'bow ow ow',
|
||||
];
|
||||
|
||||
const returnToDashboard = async () => {
|
||||
await common.navigateToApp('dashboard');
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
@ -47,6 +60,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await dashboard.waitForRenderComplete();
|
||||
};
|
||||
|
||||
const ensureAvailableOptionsEql = async (expectation: string[], skipOpen?: boolean) => {
|
||||
if (!skipOpen) await dashboardControls.optionsListOpenPopover(controlId);
|
||||
await retry.try(async () => {
|
||||
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation);
|
||||
});
|
||||
if (!skipOpen) await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']);
|
||||
|
||||
|
@ -192,53 +213,121 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
it('cannot create options list for scripted field', async () => {
|
||||
expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql(
|
||||
await dashboardControls.openCreateControlFlyout();
|
||||
expect(await dashboardControls.optionsListEditorGetCurrentDataView(false)).to.eql(
|
||||
'animals-*'
|
||||
);
|
||||
await dashboardControls.openCreateControlFlyout();
|
||||
await testSubjects.missingOrFail('field-picker-select-isDog');
|
||||
await dashboardControls.controlEditorCancel(true);
|
||||
});
|
||||
|
||||
it('can create control with non-default sorting', async () => {
|
||||
await dashboardControls.createControl({
|
||||
controlType: OPTIONS_LIST_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'sound.keyword',
|
||||
additionalSettings: {
|
||||
hideSort: true,
|
||||
defaultSortType: { by: '_key', direction: 'asc' },
|
||||
},
|
||||
});
|
||||
controlId = (await dashboardControls.getAllControlIds())[1];
|
||||
expect(await dashboardControls.getControlsCount()).to.be(2);
|
||||
|
||||
await dashboardControls.optionsListOpenPopover(controlId);
|
||||
await ensureAvailableOptionsEql([...animalSoundAvailableOptions].sort(), true);
|
||||
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
});
|
||||
|
||||
it('can edit default sorting method', async () => {
|
||||
await dashboardControls.editExistingControl(controlId);
|
||||
expect(await testSubjects.getVisibleText('optionsListControl__chooseSortBy')).to.equal(
|
||||
'Alphabetically'
|
||||
);
|
||||
const ascendingButtonSelected = await (
|
||||
await testSubjects.find('optionsListEditor__sortOrder_asc')
|
||||
).elementHasClass('uiButtonGroupButton-isSelected');
|
||||
expect(ascendingButtonSelected).to.be(true);
|
||||
const descendingButtonSelected = await (
|
||||
await testSubjects.find('optionsListEditor__sortOrder_desc')
|
||||
).elementHasClass('uiButtonGroupButton-isSelected');
|
||||
expect(descendingButtonSelected).to.be(false);
|
||||
|
||||
await dashboardControls.optionsListSetAdditionalSettings({
|
||||
defaultSortType: { by: '_key', direction: 'desc' },
|
||||
});
|
||||
await dashboardControls.controlEditorSave();
|
||||
|
||||
await dashboardControls.optionsListOpenPopover(controlId);
|
||||
await ensureAvailableOptionsEql([...animalSoundAvailableOptions].sort().reverse(), true);
|
||||
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await dashboardControls.clearAllControls();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactions between options list and dashboard', async () => {
|
||||
let controlId: string;
|
||||
|
||||
const allAvailableOptions = [
|
||||
'hiss',
|
||||
'ruff',
|
||||
'bark',
|
||||
'grrr',
|
||||
'meow',
|
||||
'growl',
|
||||
'grr',
|
||||
'bow ow ow',
|
||||
];
|
||||
|
||||
const ensureAvailableOptionsEql = async (expectation: string[], skipOpen?: boolean) => {
|
||||
if (!skipOpen) await dashboardControls.optionsListOpenPopover(controlId);
|
||||
await retry.try(async () => {
|
||||
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(
|
||||
expectation
|
||||
);
|
||||
});
|
||||
if (!skipOpen) await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
};
|
||||
|
||||
describe('Options List Control suggestions', async () => {
|
||||
before(async () => {
|
||||
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
|
||||
await dashboardControls.createControl({
|
||||
controlType: OPTIONS_LIST_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'sound.keyword',
|
||||
title: 'Animal Sounds',
|
||||
});
|
||||
|
||||
controlId = (await dashboardControls.getAllControlIds())[0];
|
||||
await dashboard.clickQuickSave();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
await dashboardControls.optionsListOpenPopover(controlId);
|
||||
});
|
||||
|
||||
it('sort alphabetically - descending', async () => {
|
||||
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'desc' });
|
||||
await dashboardControls.optionsListWaitForLoading(controlId);
|
||||
await ensureAvailableOptionsEql([...animalSoundAvailableOptions].sort().reverse(), true);
|
||||
});
|
||||
|
||||
it('sort alphabetically - ascending', async () => {
|
||||
await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'asc' });
|
||||
await dashboardControls.optionsListWaitForLoading(controlId);
|
||||
await ensureAvailableOptionsEql([...animalSoundAvailableOptions].sort(), true);
|
||||
});
|
||||
|
||||
it('sort by document count - descending', async () => {
|
||||
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
|
||||
await dashboardControls.optionsListWaitForLoading(controlId);
|
||||
await ensureAvailableOptionsEql(animalSoundAvailableOptions, true);
|
||||
});
|
||||
|
||||
it('sort by document count - ascending', async () => {
|
||||
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'asc' });
|
||||
await dashboardControls.optionsListWaitForLoading(controlId);
|
||||
// ties are broken alphabetically, so can't just reverse `animalSoundAvailableOptions` for this check
|
||||
await ensureAvailableOptionsEql(
|
||||
['bow ow ow', 'growl', 'grr', 'bark', 'grrr', 'meow', 'ruff', 'hiss'],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('non-default value should cause unsaved changes', async () => {
|
||||
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
|
||||
});
|
||||
|
||||
it('returning to default value should remove unsaved changes', async () => {
|
||||
await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' });
|
||||
await dashboardControls.optionsListWaitForLoading(controlId);
|
||||
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactions between options list and dashboard', async () => {
|
||||
before(async () => {
|
||||
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
|
||||
});
|
||||
|
||||
describe('Applies query settings to controls', async () => {
|
||||
|
@ -289,7 +378,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await dashboard.waitForRenderComplete();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
await ensureAvailableOptionsEql(allAvailableOptions);
|
||||
await ensureAvailableOptionsEql(animalSoundAvailableOptions);
|
||||
|
||||
await filterBar.toggleFilterEnabled('sound.keyword');
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
@ -317,7 +406,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await dashboard.waitForRenderComplete();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await retry.try(async () => {
|
||||
await ensureAvailableOptionsEql(allAvailableOptions);
|
||||
await ensureAvailableOptionsEql(animalSoundAvailableOptions);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -390,8 +479,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
});
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/146086
|
||||
describe.skip('test data view runtime field', async () => {
|
||||
describe('test data view runtime field', async () => {
|
||||
const FIELD_NAME = 'testRuntimeField';
|
||||
const FIELD_VALUES = ['G', 'H', 'B', 'R', 'M'];
|
||||
|
||||
|
@ -428,6 +516,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await dashboardControls.optionsListOpenPopover(controlId);
|
||||
await dashboardControls.optionsListPopoverSelectOption('B');
|
||||
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
||||
expect(await pieChart.getPieChartLabels()).to.eql(['bark', 'bow ow ow']);
|
||||
});
|
||||
|
||||
|
@ -558,7 +648,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await queryBar.submitQuery();
|
||||
await dashboard.waitForRenderComplete();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await ensureAvailableOptionsEql(allAvailableOptions);
|
||||
await ensureAvailableOptionsEql(animalSoundAvailableOptions);
|
||||
expect(await pieChart.getPieSliceCount()).to.be(2);
|
||||
});
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
ControlWidth,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types';
|
||||
import { SortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting';
|
||||
import { WebElementWrapper } from '../services/lib/web_element_wrapper';
|
||||
|
||||
import { FtrService } from '../ftr_provider_context';
|
||||
|
@ -23,14 +24,24 @@ const CONTROL_DISPLAY_NAMES: { [key: string]: string } = {
|
|||
[RANGE_SLIDER_CONTROL]: 'Range slider',
|
||||
};
|
||||
|
||||
interface OptionsListAdditionalSettings {
|
||||
defaultSortType?: SortingType;
|
||||
ignoreTimeout?: boolean;
|
||||
allowMultiple?: boolean;
|
||||
hideExclude?: boolean;
|
||||
hideExists?: boolean;
|
||||
hideSort?: boolean;
|
||||
}
|
||||
|
||||
export class DashboardPageControls extends FtrService {
|
||||
private readonly log = this.ctx.getService('log');
|
||||
private readonly find = this.ctx.getService('find');
|
||||
private readonly retry = this.ctx.getService('retry');
|
||||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
|
||||
private readonly common = this.ctx.getPageObject('common');
|
||||
private readonly header = this.ctx.getPageObject('header');
|
||||
private readonly settings = this.ctx.getPageObject('settings');
|
||||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
|
||||
/* -----------------------------------------------------------
|
||||
General controls functions
|
||||
|
@ -246,6 +257,7 @@ export class DashboardPageControls extends FtrService {
|
|||
grow,
|
||||
title,
|
||||
width,
|
||||
additionalSettings,
|
||||
}: {
|
||||
controlType: string;
|
||||
title?: string;
|
||||
|
@ -253,18 +265,24 @@ export class DashboardPageControls extends FtrService {
|
|||
width?: ControlWidth;
|
||||
dataViewTitle?: string;
|
||||
grow?: boolean;
|
||||
additionalSettings?: OptionsListAdditionalSettings;
|
||||
}) {
|
||||
this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`);
|
||||
await this.openCreateControlFlyout();
|
||||
|
||||
if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle);
|
||||
|
||||
if (fieldName) await this.controlsEditorSetfield(fieldName, controlType);
|
||||
|
||||
if (title) await this.controlEditorSetTitle(title);
|
||||
if (width) await this.controlEditorSetWidth(width);
|
||||
if (grow !== undefined) await this.controlEditorSetGrow(grow);
|
||||
|
||||
if (additionalSettings) {
|
||||
if (controlType === OPTIONS_LIST_CONTROL) {
|
||||
// only options lists currently have additional settings
|
||||
await this.optionsListSetAdditionalSettings(additionalSettings);
|
||||
}
|
||||
}
|
||||
|
||||
await this.controlEditorSave();
|
||||
}
|
||||
|
||||
|
@ -312,6 +330,29 @@ export class DashboardPageControls extends FtrService {
|
|||
}
|
||||
|
||||
// Options list functions
|
||||
public async optionsListSetAdditionalSettings({
|
||||
defaultSortType,
|
||||
ignoreTimeout,
|
||||
allowMultiple,
|
||||
hideExclude,
|
||||
hideExists,
|
||||
hideSort,
|
||||
}: OptionsListAdditionalSettings) {
|
||||
const getSettingTestSubject = (setting: string) =>
|
||||
`optionsListControl__${setting}AdditionalSetting`;
|
||||
|
||||
if (allowMultiple) await this.testSubjects.click(getSettingTestSubject('allowMultiple'));
|
||||
if (hideExclude) await this.testSubjects.click(getSettingTestSubject('hideExclude'));
|
||||
if (hideExists) await this.testSubjects.click(getSettingTestSubject('hideExists'));
|
||||
if (hideSort) await this.testSubjects.click(getSettingTestSubject('hideSort'));
|
||||
if (defaultSortType) {
|
||||
await this.testSubjects.click(`optionsListEditor__sortOrder_${defaultSortType.direction}`);
|
||||
await this.testSubjects.click('optionsListControl__chooseSortBy');
|
||||
await this.testSubjects.click(`optionsListEditor__sortBy_${defaultSortType.by}`);
|
||||
}
|
||||
if (ignoreTimeout) await this.testSubjects.click(getSettingTestSubject('runPastTimeout'));
|
||||
}
|
||||
|
||||
public async optionsListGetSelectionsString(controlId: string) {
|
||||
this.log.debug(`Getting selections string for Options List: ${controlId}`);
|
||||
const controlElement = await this.getControlElementById(controlId);
|
||||
|
@ -365,6 +406,24 @@ export class DashboardPageControls extends FtrService {
|
|||
await this.find.clickByCssSelector('.euiFormControlLayoutClearButton');
|
||||
}
|
||||
|
||||
public async optionsListPopoverSetSort(sort: SortingType) {
|
||||
this.log.debug(`select sorting type for suggestions`);
|
||||
await this.optionsListPopoverAssertOpen();
|
||||
|
||||
await this.testSubjects.click('optionsListControl__sortingOptionsButton');
|
||||
await this.retry.try(async () => {
|
||||
await this.testSubjects.existOrFail('optionsListControl__sortingOptionsPopover');
|
||||
});
|
||||
|
||||
await this.testSubjects.click(`optionsList__sortOrder_${sort.direction}`);
|
||||
await this.testSubjects.click(`optionsList__sortBy_${sort.by}`);
|
||||
|
||||
await this.testSubjects.click('optionsListControl__sortingOptionsButton');
|
||||
await this.retry.try(async () => {
|
||||
await this.testSubjects.missingOrFail(`optionsListControl__sortingOptionsPopover`);
|
||||
});
|
||||
}
|
||||
|
||||
public async optionsListPopoverSelectOption(availableOption: string) {
|
||||
this.log.debug(`selecting ${availableOption} from options list`);
|
||||
await this.optionsListPopoverAssertOpen();
|
||||
|
@ -390,6 +449,10 @@ export class DashboardPageControls extends FtrService {
|
|||
).click();
|
||||
}
|
||||
|
||||
public async optionsListWaitForLoading(controlId: string) {
|
||||
await this.testSubjects.waitForEnabled(`optionsList-control-${controlId}`);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------
|
||||
Control editor flyout
|
||||
----------------------------------------------------------- */
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue