[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:


![image](https://user-images.githubusercontent.com/8698078/202318768-cf8a5668-40c4-482f-9eb0-023508866068.png)

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:
Hannah Mudge 2022-11-24 12:46:47 -07:00 committed by GitHub
parent 94b67781e8
commit 1ed31e1e76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 802 additions and 92 deletions

View file

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

View file

@ -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 = {

View file

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

View file

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

View file

@ -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 && (

View file

@ -87,3 +87,11 @@
.optionsList--filterGroup {
width: 100%;
}
.optionsList--hiddenEditorForm {
margin-left: $euiSizeXXL + $euiSizeM;
}
.optionsList--sortPopover {
width: $euiSizeXL * 7;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()),

View file

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

View file

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