From 1ed31e1e761fdc0987b55b879f250d4ffeb896aa Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 24 Nov 2022 12:46:47 -0700 Subject: [PATCH] [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

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

### 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** ### 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))

- [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> --- .../control_group_panel_diff_system.ts | 7 + .../controls/common/options_list/mocks.tsx | 7 +- .../options_list/suggestions_sorting.ts | 31 ++++ .../controls/common/options_list/types.ts | 6 +- .../control_group/editor/control_editor.tsx | 53 +++--- .../options_list/components/options_list.scss | 8 + .../options_list_editor_options.tsx | 128 +++++++++++++- .../components/options_list_popover.test.tsx | 103 +++++++++++- .../options_list_popover_action_bar.tsx | 42 +++-- .../options_list_popover_sorting_button.tsx | 153 +++++++++++++++++ .../components/options_list_strings.ts | 54 ++++++ .../embeddable/options_list_embeddable.tsx | 5 +- .../options_list/options_list_reducers.ts | 7 + .../options_list/options_list_service.ts | 2 + src/plugins/controls/public/types.ts | 1 + .../options_list/options_list_queries.test.ts | 37 ++++ .../options_list/options_list_queries.ts | 20 ++- .../options_list_suggestions_route.ts | 1 + .../controls/options_list.ts | 158 ++++++++++++++---- .../page_objects/dashboard_page_controls.ts | 69 +++++++- 20 files changed, 801 insertions(+), 91 deletions(-) create mode 100644 src/plugins/controls/common/options_list/suggestions_sorting.ts create mode 100644 src/plugins/controls/public/options_list/components/options_list_popover_sorting_button.tsx diff --git a/src/plugins/controls/common/control_group/control_group_panel_diff_system.ts b/src/plugins/controls/common/control_group/control_group_panel_diff_system.ts index dbe9c992460b33..c412a5589cc32c 100644 --- a/src/plugins/controls/common/control_group/control_group_panel_diff_system.ts +++ b/src/plugins/controls/common/control_group/control_group_panel_diff_system.ts @@ -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 = 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) ); diff --git a/src/plugins/controls/common/options_list/mocks.tsx b/src/plugins/controls/common/options_list/mocks.tsx index c6d15ec9fcdb6e..551347b0e1058e 100644 --- a/src/plugins/controls/common/options_list/mocks.tsx +++ b/src/plugins/controls/common/options_list/mocks.tsx @@ -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 = { diff --git a/src/plugins/controls/common/options_list/suggestions_sorting.ts b/src/plugins/controls/common/options_list/suggestions_sorting.ts new file mode 100644 index 00000000000000..5289beeeb2a290 --- /dev/null +++ b/src/plugins/controls/common/options_list/suggestions_sorting.ts @@ -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 = ['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']; + } + } +}; diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index f27298f371f077..be5f252af3cc60 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -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; } diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index bce776a3922a3e..bff1506280653e 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -243,34 +243,41 @@ export const ControlEditor = ({ /> - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> - - {updateGrow ? ( - - + { - 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" /> - - ) : null} + {updateGrow && ( + <> + + { + setCurrentGrow(!currentGrow); + updateGrow(!currentGrow); + }} + data-test-subj="control-editor-grow-switch" + /> + + )} + + {CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && ( - + )} {removeControl && ( diff --git a/src/plugins/controls/public/options_list/components/options_list.scss b/src/plugins/controls/public/options_list/components/options_list.scss index 928a10f3651b87..c737dd6dc02159 100644 --- a/src/plugins/controls/public/options_list/components/options_list.scss +++ b/src/plugins/controls/public/options_list/components/options_list.scss @@ -87,3 +87,11 @@ .optionsList--filterGroup { width: 100%; } + +.optionsList--hiddenEditorForm { + margin-left: $euiSizeXXL + $euiSizeM; +} + +.optionsList--sortPopover { + width: $euiSizeXL * 7; +} diff --git a/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx b/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx index d19c907a09f4b2..80775e45508166 100644 --- a/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_editor_options.tsx @@ -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; + export const OptionsListEditorOptions = ({ initialInput, onChange, + fieldType, }: ControlEditorProps) => { const [state, setState] = useState({ - 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'} /> @@ -88,6 +142,7 @@ export const OptionsListEditorOptions = ({ setState((s) => ({ ...s, hideExclude: !s.hideExclude })); if (initialInput?.exclude) onChange({ exclude: false }); }} + data-test-subj={'optionsListControl__hideExcludeAdditionalSetting'} /> @@ -102,8 +157,74 @@ export const OptionsListEditorOptions = ({ if (initialInput?.existsSelected) onChange({ existsSelected: false }); }, }} + data-test-subj={'optionsListControl__hideExistsAdditionalSetting'} /> + + <> + { + onChange({ hideSort: !state.hideSort }); + setState((s) => ({ ...s, hideSort: !s.hideSort })); + }} + data-test-subj={'optionsListControl__hideSortAdditionalSetting'} + /> + {state.hideSort && ( + + <> + + + { + onChange({ + sort: { + direction: value as Direction, + by: state.sortBy, + }, + }); + setState((s) => ({ ...s, sortDirection: value as Direction })); + }} + legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()} + /> + + + { + onChange({ + sort: { + direction: state.sortDirection, + by: value, + }, + }); + setState((s) => ({ ...s, sortBy: value })); + }} + options={sortByOptions} + valueOfSelected={state.sortBy} + data-test-subj={'optionsListControl__chooseSortBy'} + compressed={true} + /> + + + + + + )} + + ({ ...s, runPastTimeout: !s.runPastTimeout })); }, }} + data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'} /> diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx index 1ee6de1c45763c..ef4e0a8ed0b369 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx @@ -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; + 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', + }); + }); + }); }); diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx index ad8e2eec26e435..f6be1cb0e7a62d 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx @@ -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 (
@@ -87,39 +90,44 @@ export const OptionsListPopoverActionBar = ({ )} + {!hideSort && ( + + + + )} setShowOnlySelected(!showOnlySelected)} + data-test-subj="optionsList-control-show-only-selected" aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()} - onClick={() => dispatch(clearSelections({}))} /> dispatch(clearSelections({}))} + data-test-subj="optionsList-control-clear-all-selections" aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()} - data-test-subj="optionsList-control-show-only-selected" - onClick={() => setShowOnlySelected(!showOnlySelected)} /> diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_sorting_button.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_sorting_button.tsx new file mode 100644 index 00000000000000..5facbf6b6e39d2 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_sorting_button.tsx @@ -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(); + 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(() => { + 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 ( + + setIsSortingPopoverOpen(!isSortingPopoverOpen)} + aria-label={OptionsListStrings.popover.getSortPopoverDescription()} + /> + + } + panelPaddingSize="none" + isOpen={isSortingPopoverOpen} + aria-labelledby="optionsList_sortingOptions" + closePopover={() => setIsSortingPopoverOpen(false)} + panelClassName={'optionsList--sortPopover'} + > + + + + {OptionsListStrings.popover.getSortPopoverTitle()} + + dispatch(setSort({ direction: value as Direction }))} + /> + + + + + {(list) => list} + + + + ); +}; diff --git a/src/plugins/controls/public/options_list/components/options_list_strings.ts b/src/plugins/controls/public/options_list/components/options_list_strings.ts index 98fe58f291799a..91a99c778cc135 100644 --- a/src/plugins/controls/public/options_list/components/options_list_strings.ts +++ b/src/plugins/controls/public/options_list/components/options_list_strings.ts @@ -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', + }), + }, + }, + }, }; diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx index 76256bd1a75b97..b0d40a7a620027 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx @@ -137,6 +137,7 @@ export class OptionsListEmbeddable extends Embeddable ({ searchString: { value: '', valid: true }, @@ -51,6 +52,12 @@ export const optionsListReducers = { state.componentState.searchString.valid = getIpRangeQuery(action.payload).validSearch; } }, + setSort: ( + state: WritableDraft, + action: PayloadAction> + ) => { + state.explicitInput.sort = { ...(state.explicitInput.sort ?? DEFAULT_SORT), ...action.payload }; + }, selectExists: (state: WritableDraft, action: PayloadAction) => { if (action.payload) { state.explicitInput.existsSelected = true; diff --git a/src/plugins/controls/public/services/options_list/options_list_service.ts b/src/plugins/controls/public/services/options_list/options_list_service.ts index 857a363154b7b6..bc2934e9295a60 100644 --- a/src/plugins/controls/public/services/options_list/options_list_service.ts +++ b/src/plugins/controls/public/services/options_list/options_list_service.ts @@ -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, diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 262d6632f45deb..59686af51cca61 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -57,6 +57,7 @@ export interface IEditableControlFactory export interface ControlEditorProps { initialInput?: Partial; + fieldType: string; onChange: (partial: Partial) => void; } diff --git a/src/plugins/controls/server/options_list/options_list_queries.test.ts b/src/plugins/controls/server/options_list/options_list_queries.test.ts index 40536822833da5..3be5c6da409076 100644 --- a/src/plugins/controls/server/options_list/options_list_queries.test.ts +++ b/src/plugins/controls/server/options_list/options_list_queries.test.ts @@ -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, }, }, diff --git a/src/plugins/controls/server/options_list/options_list_queries.ts b/src/plugins/controls/server/options_list/options_list_queries.ts index b150a7e17bf299..6823c33e4609c0 100644 --- a/src/plugins/controls/server/options_list/options_list_queries.ts +++ b/src/plugins/controls/server/options_list/options_list_queries.ts @@ -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), }, }, }, diff --git a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts index 95900d82cbd835..6e2f8f769815d4 100644 --- a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts +++ b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts @@ -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()), diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 9d976f5c53c303..578fc9b5e40ddb 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -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); }); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 4980898a58c1b9..87fa16a4089074 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -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 ----------------------------------------------------------- */