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 0ef2438494d7e..dbe9c992460b3 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 @@ -33,25 +33,31 @@ export const ControlPanelDiffSystems: { const { exclude: excludeA, + hideExists: hideExistsA, + hideExclude: hideExcludeA, selectedOptions: selectedA, singleSelect: singleSelectA, - hideExclude: hideExcludeA, + existsSelected: existsSelectedA, runPastTimeout: runPastTimeoutA, ...inputA }: Partial = initialInput.explicitInput; const { exclude: excludeB, + hideExists: hideExistsB, + hideExclude: hideExcludeB, selectedOptions: selectedB, singleSelect: singleSelectB, - hideExclude: hideExcludeB, + existsSelected: existsSelectedB, runPastTimeout: runPastTimeoutB, ...inputB }: Partial = newInput.explicitInput; return ( Boolean(excludeA) === Boolean(excludeB) && - Boolean(singleSelectA) === Boolean(singleSelectB) && + Boolean(hideExistsA) === Boolean(hideExistsB) && Boolean(hideExcludeA) === Boolean(hideExcludeB) && + Boolean(singleSelectA) === Boolean(singleSelectB) && + Boolean(existsSelectedA) === Boolean(existsSelectedB) && Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) && 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 ccbe3a1132479..c6d15ec9fcdb6 100644 --- a/src/plugins/controls/common/options_list/mocks.tsx +++ b/src/plugins/controls/common/options_list/mocks.tsx @@ -6,10 +6,12 @@ * Side Public License, v 1. */ -import { ReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public/redux_embeddables/types'; -import { ControlOutput } from '../../public/types'; +import { createReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public/redux_embeddables/create_redux_embeddable_tools'; + +import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public'; import { OptionsListComponentState, OptionsListReduxState } from '../../public/options_list/types'; import { optionsListReducers } from '../../public/options_list/options_list_reducers'; +import { ControlFactory, ControlOutput } from '../../public/types'; import { OptionsListEmbeddableInput } from './types'; const mockOptionsListComponentState = { @@ -36,27 +38,26 @@ const mockOptionsListOutput = { loading: false, } as ControlOutput; -export const mockOptionsListContext = ( +export const mockOptionsListReduxEmbeddableTools = async ( partialState?: Partial -): ReduxEmbeddableContext => { - const mockReduxState = { - componentState: { +) => { + const optionsListFactoryStub = new OptionsListEmbeddableFactory(); + const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory; + optionsListControlFactory.getDefaultInput = () => ({}); + const mockEmbeddable = (await optionsListControlFactory.create({ + ...mockOptionsListEmbeddableInput, + ...partialState?.explicitInput, + })) as OptionsListEmbeddable; + mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockOptionsListOutput); + + const mockReduxEmbeddableTools = createReduxEmbeddableTools({ + embeddable: mockEmbeddable, + reducers: optionsListReducers, + initialComponentState: { ...mockOptionsListComponentState, ...partialState?.componentState, }, - explicitInput: { - ...mockOptionsListEmbeddableInput, - ...partialState?.explicitInput, - }, - output: { - ...mockOptionsListOutput, - ...partialState?.output, - }, - } as OptionsListReduxState; + }); - return { - actions: {}, - useEmbeddableDispatch: () => {}, - useEmbeddableSelector: (selector: any) => selector(mockReduxState), - } as unknown as ReduxEmbeddableContext; + return mockReduxEmbeddableTools; }; diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index 59b78ee38d0b9..5a0080039e21a 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -15,9 +15,11 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; + existsSelected?: boolean; runPastTimeout?: boolean; singleSelect?: boolean; hideExclude?: boolean; + hideExists?: boolean; exclude?: boolean; } 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 53ad3990cd371..928a10f3651b8 100644 --- a/src/plugins/controls/public/options_list/components/options_list.scss +++ b/src/plugins/controls/public/options_list/components/options_list.scss @@ -35,6 +35,16 @@ font-weight: 300; } +.optionsList__existsFilter { + font-style: italic; +} + +.optionsList__negateLabel { + font-weight: bold; + font-size: $euiSizeM; + color: $euiColorDanger; +} + .optionsList__ignoredBadge { margin-left: $euiSizeS; } diff --git a/src/plugins/controls/public/options_list/components/options_list_control.test.tsx b/src/plugins/controls/public/options_list/components/options_list_control.test.tsx new file mode 100644 index 0000000000000..a4d5028f0f7be --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_control.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { OptionsListComponentState, OptionsListReduxState } from '../types'; +import { ControlOutput, OptionsListEmbeddableInput } from '../..'; +import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks'; +import { OptionsListControl } from './options_list_control'; +import { BehaviorSubject } from 'rxjs'; + +describe('Options list control', () => { + const defaultProps = { + typeaheadSubject: new BehaviorSubject(''), + }; + + interface MountOptions { + componentState: Partial; + explicitInput: Partial; + output: Partial; + } + + async function mountComponent(options?: Partial) { + const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({ + componentState: options?.componentState ?? {}, + explicitInput: options?.explicitInput ?? {}, + output: options?.output ?? {}, + } as Partial); + + return mountWithIntl( + + + + ); + } + + test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => { + const control = await mountComponent({ + explicitInput: { id: 'testExists', exclude: false, existsSelected: true }, + }); + const existsOption = findTestSubject(control, 'optionsList-control-testExists'); + expect(existsOption.text()).toBe('Exists'); + }); + + test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => { + const control = await mountComponent({ + explicitInput: { id: 'testDoesNotExist', exclude: true, existsSelected: true }, + }); + const existsOption = findTestSubject(control, 'optionsList-control-testDoesNotExist'); + expect(existsOption.text()).toBe('DOES NOT Exist'); + }); +}); diff --git a/src/plugins/controls/public/options_list/components/options_list_control.tsx b/src/plugins/controls/public/options_list/components/options_list_control.tsx index 93bf0cbef864e..1f19382ab506b 100644 --- a/src/plugins/controls/public/options_list/components/options_list_control.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_control.tsx @@ -11,13 +11,7 @@ import classNames from 'classnames'; import { debounce, isEmpty } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { - EuiFilterButton, - EuiFilterGroup, - EuiPopover, - EuiTextColor, - useResizeObserver, -} from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { OptionsListStrings } from './options_list_strings'; @@ -46,10 +40,11 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub const validSelections = select((state) => state.componentState.validSelections); const selectedOptions = select((state) => state.explicitInput.selectedOptions); + const existsSelected = select((state) => state.explicitInput.existsSelected); const controlStyle = select((state) => state.explicitInput.controlStyle); const singleSelect = select((state) => state.explicitInput.singleSelect); - const id = select((state) => state.explicitInput.id); const exclude = select((state) => state.explicitInput.exclude); + const id = select((state) => state.explicitInput.id); const loading = select((state) => state.output.loading); @@ -83,22 +78,34 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub selectionDisplayNode: ( <> {exclude && ( - - {OptionsListStrings.control.getNegate()}{' '} - - )} - {validSelections && ( - {validSelections?.join(OptionsListStrings.control.getSeparator())} + <> + + {existsSelected + ? OptionsListStrings.control.getExcludeExists() + : OptionsListStrings.control.getNegate()} + {' '} + )} - {invalidSelections && ( - - {invalidSelections.join(OptionsListStrings.control.getSeparator())} + {existsSelected ? ( + + {OptionsListStrings.controlAndPopover.getExists(+Boolean(exclude))} + ) : ( + <> + {validSelections && ( + {validSelections?.join(OptionsListStrings.control.getSeparator())} + )} + {invalidSelections && ( + + {invalidSelections.join(OptionsListStrings.control.getSeparator())} + + )} + )} ), }; - }, [exclude, validSelections, invalidSelections]); + }, [exclude, existsSelected, validSelections, invalidSelections]); const button = (
@@ -115,7 +122,9 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub numActiveFilters={validSelectionsCount} hasActiveFilters={Boolean(validSelectionsCount)} > - {hasSelections ? selectionDisplayNode : OptionsListStrings.control.getPlaceholder()} + {hasSelections || existsSelected + ? selectionDisplayNode + : OptionsListStrings.control.getPlaceholder()}
); @@ -136,6 +145,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub className="optionsList__popoverOverride" closePopover={() => setIsPopoverOpen(false)} anchorClassName="optionsList__anchorOverride" + aria-labelledby={`control-popover-${id}`} > 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 0723fcaad6ba6..d19c907a09f4b 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 @@ -8,16 +8,28 @@ import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, EuiSwitch } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; import { css } from '@emotion/react'; import { OptionsListStrings } from './options_list_strings'; import { ControlEditorProps, OptionsListEmbeddableInput } from '../..'; - interface OptionsListEditorState { singleSelect?: boolean; runPastTimeout?: boolean; hideExclude?: boolean; + hideExists?: boolean; +} + +interface SwitchProps { + checked: boolean; + onChange: (event: EuiSwitchEvent) => void; } export const OptionsListEditorOptions = ({ @@ -28,8 +40,33 @@ export const OptionsListEditorOptions = ({ singleSelect: initialInput?.singleSelect, runPastTimeout: initialInput?.runPastTimeout, hideExclude: initialInput?.hideExclude, + hideExists: initialInput?.hideExists, }); + const SwitchWithTooltip = ({ + switchProps, + label, + tooltip, + }: { + switchProps: SwitchProps; + label: string; + tooltip: string; + }) => ( + + + + + + + + + ); + return ( <> @@ -54,29 +91,31 @@ export const OptionsListEditorOptions = ({ /> - - - { - onChange({ runPastTimeout: !state.runPastTimeout }); - setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); - }} - /> - - - - - + { + onChange({ hideExists: !state.hideExists }); + setState((s) => ({ ...s, hideExists: !s.hideExists })); + if (initialInput?.existsSelected) onChange({ existsSelected: false }); + }, + }} + /> + + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }, + }} + /> ); 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 eca6fe72376a1..1ee6de1c45763 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 @@ -11,12 +11,11 @@ import { ReactWrapper } from 'enzyme'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { EmbeddableReduxContext } from '@kbn/presentation-util-plugin/public/redux_embeddables/use_redux_embeddable_context'; import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover'; import { OptionsListComponentState, OptionsListReduxState } from '../types'; import { ControlOutput, OptionsListEmbeddableInput } from '../..'; -import { mockOptionsListContext } from '../../../common/mocks'; +import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks'; describe('Options list popover', () => { const defaultProps = { @@ -31,18 +30,18 @@ describe('Options list popover', () => { popoverProps: Partial; } - function mountComponent(options?: Partial) { + async function mountComponent(options?: Partial) { const compProps = { ...defaultProps, ...(options?.popoverProps ?? {}) }; - const context = mockOptionsListContext({ + const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({ componentState: options?.componentState ?? {}, explicitInput: options?.explicitInput ?? {}, output: options?.output ?? {}, } as Partial); return mountWithIntl( - + - + ); } @@ -54,19 +53,19 @@ describe('Options list popover', () => { showOnlySelectedButton.simulate('click'); }; - test('available options list width responds to container size', () => { - let popover = mountComponent({ popoverProps: { width: 301 } }); + test('available options list width responds to container size', async () => { + let popover = await mountComponent({ popoverProps: { width: 301 } }); let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); expect(availableOptionsDiv.getDOMNode().getAttribute('style')).toBe('width: 301px;'); // the div cannot be smaller than 301 pixels wide - popover = mountComponent({ popoverProps: { width: 300 } }); + popover = await mountComponent({ popoverProps: { width: 300 } }); availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); expect(availableOptionsDiv.getDOMNode().getAttribute('style')).toBe(null); }); - test('no available options', () => { - const popover = mountComponent({ componentState: { availableOptions: [] } }); + test('no available options', async () => { + const popover = await mountComponent({ componentState: { availableOptions: [] } }); const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); const noOptionsDiv = findTestSubject( availableOptionsDiv, @@ -75,8 +74,8 @@ describe('Options list popover', () => { expect(noOptionsDiv.exists()).toBeTruthy(); }); - test('display error message when the show only selected toggle is true but there are no selections', () => { - const popover = mountComponent(); + test('display error message when the show only selected toggle is true but there are no selections', async () => { + const popover = await mountComponent(); clickShowOnlySelections(popover); const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); const noSelectionsDiv = findTestSubject( @@ -86,28 +85,31 @@ describe('Options list popover', () => { expect(noSelectionsDiv.exists()).toBeTruthy(); }); - test('show only selected options', () => { + test('show only selected options', async () => { const selections = ['woof', 'bark']; - const popover = mountComponent({ + const popover = await mountComponent({ explicitInput: { selectedOptions: selections }, }); clickShowOnlySelections(popover); const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - availableOptionsDiv.children().forEach((child, i) => { - expect(child.text()).toBe(selections[i]); - }); + availableOptionsDiv + .childAt(0) + .children() + .forEach((child, i) => { + expect(child.text()).toBe(selections[i]); + }); }); - test('should default to exclude = false', () => { - const popover = mountComponent(); + test('should default to exclude = false', async () => { + const popover = await mountComponent(); const includeButton = findTestSubject(popover, 'optionsList__includeResults'); const excludeButton = findTestSubject(popover, 'optionsList__excludeResults'); expect(includeButton.prop('checked')).toBe(true); expect(excludeButton.prop('checked')).toBeFalsy(); }); - test('if exclude = true, select appropriate button in button group', () => { - const popover = mountComponent({ + test('if exclude = true, select appropriate button in button group', async () => { + const popover = await mountComponent({ explicitInput: { exclude: true }, }); const includeButton = findTestSubject(popover, 'optionsList__includeResults'); @@ -115,4 +117,56 @@ describe('Options list popover', () => { expect(includeButton.prop('checked')).toBeFalsy(); expect(excludeButton.prop('checked')).toBe(true); }); + + test('clicking another option unselects "Exists"', async () => { + const popover = await mountComponent({ + explicitInput: { existsSelected: true }, + }); + const woofOption = findTestSubject(popover, 'optionsList-control-selection-woof'); + woofOption.simulate('click'); + + const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (child.text() === 'woof') expect(child.prop('checked')).toBe('on'); + else expect(child.prop('checked')).toBeFalsy(); + }); + }); + + test('clicking "Exists" unselects all other selections', async () => { + const selections = ['woof', 'bark']; + const popover = await mountComponent({ + explicitInput: { existsSelected: false, selectedOptions: selections }, + }); + const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); + let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (selections.includes(child.text())) expect(child.prop('checked')).toBe('on'); + else expect(child.prop('checked')).toBeFalsy(); + }); + + existsOption.simulate('click'); + availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + availableOptionsDiv.children().forEach((child, i) => { + if (child.text() === 'Exists (*)') expect(child.prop('checked')).toBe('on'); + else expect(child.prop('checked')).toBeFalsy(); + }); + }); + + test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => { + const popover = await mountComponent({ + componentState: { availableOptions: [] }, + explicitInput: { existsSelected: false }, + }); + const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); + expect(existsOption.exists()).toBeFalsy(); + }); + + test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => { + const popover = await mountComponent({ + explicitInput: { existsSelected: true }, + }); + clickShowOnlySelections(popover); + const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); + expect(availableOptionsDiv.children().at(0).text()).toBe('Exists'); + }); }); diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.tsx index 48be0d9253285..bc1e62fccfda7 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover.tsx @@ -6,286 +6,70 @@ * Side Public License, v 1. */ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { isEmpty } from 'lodash'; -import { - EuiFilterSelectItem, - EuiPopoverTitle, - EuiFieldSearch, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiToolTip, - EuiSpacer, - EuiBadge, - EuiIcon, - EuiTitle, - EuiPopoverFooter, - EuiButtonGroup, - useEuiBackgroundColor, -} from '@elastic/eui'; -import { css } from '@emotion/react'; +import { EuiPopoverTitle } from '@elastic/eui'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { optionsListReducers } from '../options_list_reducers'; import { OptionsListReduxState } from '../types'; import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from '../options_list_reducers'; +import { OptionsListPopoverFooter } from './options_list_popover_footer'; +import { OptionsListPopoverActionBar } from './options_list_popover_action_bar'; +import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions'; +import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections'; export interface OptionsListPopoverProps { width: number; updateSearchString: (newSearchString: string) => void; } -const aggregationToggleButtons = [ - { - id: 'optionsList__includeResults', - label: OptionsListStrings.popover.getIncludeLabel(), - }, - { - id: 'optionsList__excludeResults', - label: OptionsListStrings.popover.getExcludeLabel(), - }, -]; - export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPopoverProps) => { // Redux embeddable container Context - const { - useEmbeddableDispatch, - useEmbeddableSelector: select, - actions: { selectOption, deselectOption, clearSelections, replaceSelection, setExclude }, - } = useReduxEmbeddableContext(); - - const dispatch = useEmbeddableDispatch(); + const { useEmbeddableSelector: select } = useReduxEmbeddableContext< + OptionsListReduxState, + typeof optionsListReducers + >(); // Select current state from Redux using multiple selectors to avoid rerenders. const invalidSelections = select((state) => state.componentState.invalidSelections); - const totalCardinality = select((state) => state.componentState.totalCardinality); const availableOptions = select((state) => state.componentState.availableOptions); - const searchString = select((state) => state.componentState.searchString); const field = select((state) => state.componentState.field); - const selectedOptions = select((state) => state.explicitInput.selectedOptions); const hideExclude = select((state) => state.explicitInput.hideExclude); - const singleSelect = select((state) => state.explicitInput.singleSelect); + const fieldName = select((state) => state.explicitInput.fieldName); const title = select((state) => state.explicitInput.title); - const exclude = select((state) => state.explicitInput.exclude); - - const loading = select((state) => state.output.loading); - - // track selectedOptions and invalidSelections in sets for more efficient lookup - const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); - const invalidSelectionsSet = useMemo( - () => new Set(invalidSelections), - [invalidSelections] - ); + const id = select((state) => state.explicitInput.id); const [showOnlySelected, setShowOnlySelected] = useState(false); - const euiBackgroundColor = useEuiBackgroundColor('subdued'); return ( - <> + {title} {field?.type !== 'boolean' && ( -
- - - - updateSearchString(event.target.value)} - value={searchString.value} - data-test-subj="optionsList-control-search-input" - placeholder={ - totalCardinality - ? OptionsListStrings.popover.getTotalCardinalityPlaceholder(totalCardinality) - : undefined - } - /> - - - {invalidSelections && invalidSelections.length > 0 && ( - - - {invalidSelections.length} - - - )} - - - - dispatch(clearSelections({}))} - /> - - - - - setShowOnlySelected(!showOnlySelected)} - /> - - - - -
+ )}
300 ? width : undefined }} - className="optionsList__items" + className="optionsList __items" data-option-count={availableOptions?.length ?? 0} data-test-subj={`optionsList-control-available-options`} > - {!showOnlySelected && ( - <> - {availableOptions?.map((availableOption, index) => ( - { - if (singleSelect) { - dispatch(replaceSelection(availableOption)); - return; - } - if (selectedOptionsSet.has(availableOption)) { - dispatch(deselectOption(availableOption)); - return; - } - dispatch(selectOption(availableOption)); - }} - > - {`${availableOption}`} - - ))} - - {!loading && (!availableOptions || availableOptions.length === 0) && ( -
-
- - -

{OptionsListStrings.popover.getEmptyMessage()}

-
-
- )} - - {!isEmpty(invalidSelections) && ( - <> - - - - - <> - {invalidSelections?.map((ignoredSelection, index) => ( - dispatch(deselectOption(ignoredSelection))} - > - {`${ignoredSelection}`} - - ))} - - - )} - - )} - {showOnlySelected && ( - <> - {selectedOptions && - selectedOptions.map((availableOption, index) => ( - dispatch(deselectOption(availableOption))} - className={ - invalidSelectionsSet.has(availableOption) - ? 'optionsList__selectionInvalid' - : undefined - } - > - {`${availableOption}`} - - ))} - {(!selectedOptions || selectedOptions.length === 0) && ( -
-
- - -

{OptionsListStrings.popover.getSelectionsEmptyMessage()}

-
-
- )} - + + {!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && ( + )}
- {!hideExclude && ( - - - dispatch(setExclude(optionId === 'optionsList__excludeResults')) - } - buttonSize="compressed" - data-test-subj="optionsList__includeExcludeButtonGroup" - /> - - )} - + {!hideExclude && } +
); }; 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 new file mode 100644 index 0000000000000..ad8e2eec26e43 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_action_bar.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { + EuiFieldSearch, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiToolTip, + EuiBadge, +} from '@elastic/eui'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; + +import { OptionsListReduxState } from '../types'; +import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from '../options_list_reducers'; + +interface OptionsListPopoverProps { + showOnlySelected: boolean; + setShowOnlySelected: (value: boolean) => void; + updateSearchString: (newSearchString: string) => void; +} + +export const OptionsListPopoverActionBar = ({ + showOnlySelected, + setShowOnlySelected, + updateSearchString, +}: OptionsListPopoverProps) => { + // Redux embeddable container Context + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { clearSelections }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState.invalidSelections); + const totalCardinality = select((state) => state.componentState.totalCardinality); + const searchString = select((state) => state.componentState.searchString); + + return ( +
+ + + + updateSearchString(event.target.value)} + value={searchString.value} + data-test-subj="optionsList-control-search-input" + placeholder={ + totalCardinality + ? OptionsListStrings.popover.getTotalCardinalityPlaceholder(totalCardinality) + : undefined + } + autoFocus={true} + /> + + + {(invalidSelections?.length ?? 0) > 0 && ( + + + {invalidSelections?.length} + + + )} + + + + dispatch(clearSelections({}))} + /> + + + + + setShowOnlySelected(!showOnlySelected)} + /> + + + + +
+ ); +}; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx new file mode 100644 index 0000000000000..8a51a33a31ba0 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiPopoverFooter, EuiButtonGroup, useEuiBackgroundColor } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; + +import { OptionsListReduxState } from '../types'; +import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from '../options_list_reducers'; + +const aggregationToggleButtons = [ + { + id: 'optionsList__includeResults', + label: OptionsListStrings.popover.getIncludeLabel(), + }, + { + id: 'optionsList__excludeResults', + label: OptionsListStrings.popover.getExcludeLabel(), + }, +]; + +export const OptionsListPopoverFooter = () => { + // Redux embeddable container Context + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { setExclude }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const exclude = select((state) => state.explicitInput.exclude); + + return ( + <> + + dispatch(setExclude(optionId === 'optionsList__excludeResults'))} + buttonSize="compressed" + data-test-subj="optionsList__includeExcludeButtonGroup" + /> + + + ); +}; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx new file mode 100644 index 0000000000000..1a6ec2176dd42 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_invalid_selections.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiFilterSelectItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; + +import { OptionsListReduxState } from '../types'; +import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from '../options_list_reducers'; + +export const OptionsListPopoverInvalidSelections = () => { + // Redux embeddable container Context + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { deselectOption }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState.invalidSelections); + + return ( + <> + + + + + {invalidSelections?.map((ignoredSelection, index) => ( + dispatch(deselectOption(ignoredSelection))} + > + {`${ignoredSelection}`} + + ))} + + ); +}; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx new file mode 100644 index 0000000000000..5ca609d6ac64d --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx @@ -0,0 +1,123 @@ +/* + * 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 } from 'react'; + +import { EuiFilterSelectItem, EuiSpacer, EuiIcon } from '@elastic/eui'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; + +import { OptionsListReduxState } from '../types'; +import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from '../options_list_reducers'; + +interface OptionsListPopoverSuggestionsProps { + showOnlySelected: boolean; +} + +export const OptionsListPopoverSuggestions = ({ + showOnlySelected, +}: OptionsListPopoverSuggestionsProps) => { + // Redux embeddable container Context + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { replaceSelection, deselectOption, selectOption, selectExists }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState.invalidSelections); + const availableOptions = select((state) => state.componentState.availableOptions); + + const selectedOptions = select((state) => state.explicitInput.selectedOptions); + const existsSelected = select((state) => state.explicitInput.existsSelected); + const singleSelect = select((state) => state.explicitInput.singleSelect); + const hideExists = select((state) => state.explicitInput.hideExists); + + const loading = select((state) => state.output.loading); + + // track selectedOptions and invalidSelections in sets for more efficient lookup + const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); + const invalidSelectionsSet = useMemo( + () => new Set(invalidSelections), + [invalidSelections] + ); + const suggestions = showOnlySelected ? selectedOptions : availableOptions; + + if ( + !loading && + (!suggestions || suggestions.length === 0) && + !(showOnlySelected && existsSelected) + ) { + return ( +
+
+ + +

+ {showOnlySelected + ? OptionsListStrings.popover.getSelectionsEmptyMessage() + : OptionsListStrings.popover.getEmptyMessage()} +

+
+
+ ); + } + + return ( + <> + {!hideExists && !(showOnlySelected && !existsSelected) && ( + { + dispatch(selectExists(!Boolean(existsSelected))); + }} + className="optionsList__existsFilter" + > + {OptionsListStrings.controlAndPopover.getExists()} + + )} + {suggestions?.map((suggestion, index) => ( + { + if (showOnlySelected) { + dispatch(deselectOption(suggestion)); + return; + } + if (singleSelect) { + dispatch(replaceSelection(suggestion)); + return; + } + if (selectedOptionsSet.has(suggestion)) { + dispatch(deselectOption(suggestion)); + return; + } + dispatch(selectOption(suggestion)); + }} + className={ + showOnlySelected && invalidSelectionsSet.has(suggestion) + ? 'optionsList__selectionInvalid' + : undefined + } + > + {`${suggestion}`} + + ))} + + ); +}; 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 cfab9633b81e9..5db1ddeae21b0 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 @@ -22,6 +22,10 @@ export const OptionsListStrings = { i18n.translate('controls.optionsList.control.negate', { defaultMessage: 'NOT', }), + getExcludeExists: () => + i18n.translate('controls.optionsList.control.excludeExists', { + defaultMessage: 'DOES NOT', + }), }, editor: { getAllowMultiselectTitle: () => @@ -41,8 +45,21 @@ export const OptionsListStrings = { i18n.translate('controls.optionsList.editor.hideExclude', { defaultMessage: 'Allow selections to be excluded', }), + getHideExistsQueryTitle: () => + i18n.translate('controls.optionsList.editor.hideExistsQuery', { + defaultMessage: 'Allow exists query', + }), + getHideExistsQueryTooltip: () => + i18n.translate('controls.optionsList.editor.hideExistsQueryTooltip', { + defaultMessage: 'Returns the documents that contain an indexed value for the field.', + }), }, popover: { + getAriaLabel: (fieldName: string) => + i18n.translate('controls.optionsList.popover.ariaLabel', { + defaultMessage: 'Popover for {fieldName} control', + values: { fieldName }, + }), getLoadingMessage: () => i18n.translate('controls.optionsList.popover.loading', { defaultMessage: 'Loading options', @@ -112,4 +129,11 @@ export const OptionsListStrings = { defaultMessage: 'Include or exclude selections', }), }, + controlAndPopover: { + getExists: (negate: number = +false) => + i18n.translate('controls.optionsList.controlAndPopover.exists', { + defaultMessage: '{negate, plural, one {Exist} other {Exists}}', + values: { negate }, + }), + }, }; 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 ffead8d9c20bc..76256bd1a75b9 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 @@ -21,6 +21,7 @@ import { buildPhraseFilter, buildPhrasesFilter, COMPARE_ALL_OPTIONS, + buildExistsFilter, } from '@kbn/es-query'; import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -128,13 +129,14 @@ export class OptionsListEmbeddable extends Embeddable ({ validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, + existsSelected: newInput.existsSelected, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, timeRange: newInput.timeRange, timeslice: newInput.timeslice, + exclude: newInput.exclude, filters: newInput.filters, query: newInput.query, - exclude: newInput.exclude, })), distinctUntilChanged(diffDataFetchProps) ); @@ -156,7 +158,10 @@ export class OptionsListEmbeddable extends Embeddable isEqual(a.selectedOptions, b.selectedOptions) && a.exclude === b.exclude + (a, b) => + a.exclude === b.exclude && + a.existsSelected === b.existsSelected && + isEqual(a.selectedOptions, b.selectedOptions) ) ) .subscribe(async ({ selectedOptions: newSelectedOptions }) => { @@ -266,7 +271,7 @@ export class OptionsListEmbeddable extends Embeddable { const { getState } = this.reduxEmbeddableTools; const { validSelections } = getState().componentState ?? {}; + const { existsSelected } = getState().explicitInput ?? {}; const { exclude } = this.getInput(); - if (!validSelections || isEmpty(validSelections)) { + if ((!validSelections || isEmpty(validSelections)) && !existsSelected) { return []; } const { dataView, field } = await this.getCurrentDataViewAndField(); if (!dataView || !field) return; - let newFilter: Filter; - if (validSelections.length === 1) { - newFilter = buildPhraseFilter(field, validSelections[0], dataView); - } else { - newFilter = buildPhrasesFilter(field, validSelections, dataView); + let newFilter: Filter | undefined; + if (existsSelected) { + newFilter = buildExistsFilter(field, dataView); + } else if (validSelections) { + if (validSelections.length === 1) { + newFilter = buildPhraseFilter(field, validSelections[0], dataView); + } else { + newFilter = buildPhrasesFilter(field, validSelections, dataView); + } } + if (!newFilter) return []; newFilter.meta.key = field?.name; if (exclude) newFilter.meta.negate = true; diff --git a/src/plugins/controls/public/options_list/options_list_reducers.ts b/src/plugins/controls/public/options_list/options_list_reducers.ts index 1a8e2dcb4683e..731ae4c8eb507 100644 --- a/src/plugins/controls/public/options_list/options_list_reducers.ts +++ b/src/plugins/controls/public/options_list/options_list_reducers.ts @@ -51,8 +51,17 @@ export const optionsListReducers = { state.componentState.searchString.valid = getIpRangeQuery(action.payload).validSearch; } }, + selectExists: (state: WritableDraft, action: PayloadAction) => { + if (action.payload) { + state.explicitInput.existsSelected = true; + state.explicitInput.selectedOptions = []; + } else { + state.explicitInput.existsSelected = false; + } + }, selectOption: (state: WritableDraft, action: PayloadAction) => { if (!state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = []; + if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false; state.explicitInput.selectedOptions?.push(action.payload); }, replaceSelection: ( @@ -62,6 +71,7 @@ export const optionsListReducers = { state.explicitInput.selectedOptions = [action.payload]; }, clearSelections: (state: WritableDraft) => { + if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false; if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = []; }, setExclude: (state: WritableDraft, action: PayloadAction) => { 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 a537e7534a3b8..27867b5724cec 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 @@ -82,6 +82,7 @@ class OptionsListService implements ControlsOptionsListService { const timeFilter = timeRange ? timeService.createFilter(dataView, timeRange) : undefined; const filtersToUse = [...(filters ?? []), ...(timeFilter ? [timeFilter] : [])]; const esFilters = [buildEsQuery(dataView, query ?? [], filtersToUse ?? [])]; + return { ...passThroughProps, filters: esFilters, 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 d1fa89bbc9358..4a381aeac64c1 100644 --- a/src/plugins/controls/server/options_list/options_list_queries.ts +++ b/src/plugins/controls/server/options_list/options_list_queries.ts @@ -27,11 +27,13 @@ interface EsBucket { */ export const getValidationAggregationBuilder: () => OptionsListAggregationBuilder = () => ({ buildAggregation: ({ selectedOptions, fieldName }: OptionsListRequestBody) => { - const selectedOptionsFilters = selectedOptions?.reduce((acc, currentOption) => { - acc[currentOption] = { match: { [fieldName]: currentOption } }; - return acc; - }, {} as { [key: string]: { match: { [key: string]: string } } }); - + let selectedOptionsFilters; + if (selectedOptions) { + selectedOptionsFilters = selectedOptions.reduce((acc, currentOption) => { + acc[currentOption] = { match: { [fieldName]: currentOption } }; + return acc; + }, {} as { [key: string]: { match: { [key: string]: string } } }); + } return selectedOptionsFilters && !isEmpty(selectedOptionsFilters) ? { filters: { @@ -44,6 +46,7 @@ export const getValidationAggregationBuilder: () => OptionsListAggregationBuilde const rawInvalidSuggestions = get(rawEsResult, 'aggregations.validation.buckets') as { [key: string]: { doc_count: number }; }; + return rawInvalidSuggestions && !isEmpty(rawInvalidSuggestions) ? Object.entries(rawInvalidSuggestions) ?.filter(([, value]) => value?.doc_count === 0) 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 c9af30bb07b82..fe2218c3f7135 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 @@ -107,7 +107,6 @@ export const setupOptionsListSuggestionsRoute = ( validation: builtValidationAggregation, } : {}; - const body: SearchRequest['body'] = { size: 0, ...timeoutSettings, @@ -138,7 +137,6 @@ export const setupOptionsListSuggestionsRoute = ( const totalCardinality = get(rawEsResult, 'aggregations.unique_terms.value'); const suggestions = suggestionBuilder.parse(rawEsResult); const invalidSelections = validationBuilder.parse(rawEsResult); - return { suggestions, totalCardinality, diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts index 89a435430f9e9..652864471a04a 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -14,26 +14,58 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const security = getService('security'); - const { dashboardControls, common, dashboard, timePicker } = getPageObjects([ + const { common, console, dashboard, dashboardControls, header, timePicker } = getPageObjects([ 'dashboardControls', 'timePicker', 'dashboard', + 'console', 'common', + 'header', ]); describe('Dashboard control group hierarchical chaining', () => { + const newDocuments: Array<{ index: string; id: string }> = []; let controlIds: string[]; - const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + const ensureAvailableOptionsEql = async ( + controlId: string, + expectation: string[], + filterOutExists: boolean = true + ) => { await dashboardControls.optionsListOpenPopover(controlId); await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation); + expect( + await dashboardControls.optionsListPopoverGetAvailableOptions(filterOutExists) + ).to.eql(expectation); }); await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); }; + const addDocument = async (index: string, document: string) => { + await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + const response = JSON.parse(await console.getResponse()); + newDocuments.push({ index, id: response._id }); + }; + before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + + /* start by adding some incomplete data so that we can test `exists` query */ + await common.navigateToApp('console'); + await console.collapseHelp(); + await console.clearTextArea(); + await addDocument( + 'animals-cats-2018-01-01', + '"@timestamp": "2018-01-01T16:00:00.000Z", \n"animal": "cat"' + ); + await addDocument( + 'animals-dogs-2018-01-01', + '"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Max", \n"sound": "woof"' + ); + + /* then, create our testing dashboard */ await common.navigateToApp('dashboard'); await dashboard.gotoDashboardLandingPage(); await dashboard.clickNewDashboard(); @@ -65,6 +97,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { + await common.navigateToApp('console'); + await console.collapseHelp(); + await console.clearTextArea(); + for (const { index, id } of newDocuments) { + await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + } await security.testUser.restoreDefaults(); }); @@ -128,7 +168,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListPopoverSetIncludeSelections(false); await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester', 'Max']); await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); }); @@ -138,9 +178,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); await dashboardControls.optionsListOpenPopover(controlIds[1]); - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); + await dashboardControls.optionsListOpenPopover(controlIds[2]); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Creating "does not exist" query from first control filters the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('exists'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + await dashboard.waitForRenderComplete(); + + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverClearSelections(); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(['Max']); + + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverClearSelections(); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(['woof']); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Creating "exists" query from first control filters the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSetIncludeSelections(true); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + await dashboard.waitForRenderComplete(); + + await dashboardControls.optionsListOpenPopover(controlIds[1]); + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.not.contain('Max'); await dashboardControls.optionsListOpenPopover(controlIds[2]); - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.not.contain( + 'woof' + ); await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); }); @@ -151,7 +224,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('Selecting an option in the first Options List will not filter the second or third controls', async () => { await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverSetIncludeSelections(true); await dashboardControls.optionsListPopoverSelectOption('cat'); await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); @@ -161,6 +233,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'sylvester', 'Fee Fee', 'Rover', + 'Max', ]); await ensureAvailableOptionsEql(controlIds[2], [ 'hiss', @@ -171,6 +244,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'growl', 'grr', 'bow ow ow', + 'woof', ]); }); }); diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 091f893eec2cf..6cfe2c31fa0c1 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -21,10 +21,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); - const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ + + const { dashboardControls, timePicker, console, common, dashboard, header } = getPageObjects([ 'dashboardControls', 'timePicker', 'dashboard', + 'console', 'common', 'header', ]); @@ -32,8 +34,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const DASHBOARD_NAME = 'Test Options List Control'; describe('Dashboard options list integration', () => { + const newDocuments: Array<{ index: string; id: string }> = []; + + const addDocument = async (index: string, document: string) => { + await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + const response = JSON.parse(await console.getResponse()); + newDocuments.push({ index, id: response._id }); + }; + before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + + /* start by adding some incomplete data so that we can test `exists` query */ + await common.navigateToApp('console'); + await console.collapseHelp(); + await console.clearTextArea(); + await addDocument( + 'animals-cats-2018-01-01', + '"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"' + ); + + /* then, create our testing dashboard */ await common.navigateToApp('dashboard'); await dashboard.gotoDashboardLandingPage(); await dashboard.clickNewDashboard(); @@ -215,7 +238,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Applies query settings to controls', async () => { it('Applies dashboard query to options list control', async () => { - await queryBar.setQuery('isDog : true '); + await queryBar.setQuery('animal.keyword : "dog" '); await queryBar.submitQuery(); await dashboard.waitForRenderComplete(); await header.waitUntilLoadingHasFinished(); @@ -336,10 +359,75 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); expect(selectionString).to.be('hiss, grr'); + }); + + it('excluding selections has expected results', async () => { + await dashboard.clickQuickSave(); + await dashboard.waitForRenderComplete(); await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSetIncludeSelections(false); await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(5); + await dashboard.clearUnsavedChanges(); + }); + + it('including selections has expected results', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSetIncludeSelections(true); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(2); + await dashboard.clearUnsavedChanges(); + }); + + describe('test exists query', async () => { + before(async () => { + await dashboardControls.deleteAllControls(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + }); + + it('creating exists query has expected results', async () => { + expect((await pieChart.getPieChartValues())[0]).to.be(6); + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('exists'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(5); + expect((await pieChart.getPieChartValues())[0]).to.be(5); + }); + + it('negating exists query has expected results', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSetIncludeSelections(false); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(1); + expect((await pieChart.getPieChartValues())[0]).to.be(1); + }); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; }); }); @@ -359,7 +447,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Can mark selections invalid with Query', async () => { - await queryBar.setQuery('isDog : false '); + await queryBar.setQuery('NOT animal.keyword : "dog" '); await queryBar.submitQuery(); await dashboard.waitForRenderComplete(); await header.waitUntilLoadingHasFinished(); @@ -385,27 +473,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await pieChart.getPieSliceCount()).to.be(2); }); - it('excluding selections has expected results', async () => { - await dashboard.clickQuickSave(); - await dashboard.waitForRenderComplete(); - - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSetIncludeSelections(false); - await dashboard.waitForRenderComplete(); - - expect(await pieChart.getPieSliceCount()).to.be(5); - await dashboard.clearUnsavedChanges(); - }); - - it('including selections has expected results', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSetIncludeSelections(true); - await dashboard.waitForRenderComplete(); - - expect(await pieChart.getPieSliceCount()).to.be(2); - await dashboard.clearUnsavedChanges(); - }); - it('Can mark multiple selections invalid with Filter', async () => { await filterBar.addFilter('sound.keyword', 'is', ['hiss']); await dashboard.waitForRenderComplete(); @@ -429,7 +496,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Does not mark selections invalid with Query', async () => { - await queryBar.setQuery('isDog : false '); + await queryBar.setQuery('NOT animal.keyword : "dog" '); await queryBar.submitQuery(); await dashboard.waitForRenderComplete(); await header.waitUntilLoadingHasFinished(); @@ -448,8 +515,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.removeAllFilters(); await queryBar.clickQuerySubmitButton(); await dashboardControls.clearAllControls(); - await security.testUser.restoreDefaults(); }); }); + + after(async () => { + await common.navigateToApp('console'); + await console.collapseHelp(); + await console.clearTextArea(); + for (const { index, id } of newDocuments) { + await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + } + await security.testUser.restoreDefaults(); + }); }); } diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 1e04ebb467d89..461734c61d0ce 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -346,10 +346,11 @@ export class DashboardPageControls extends FtrService { return +(await availableOptions.getAttribute('data-option-count')); } - public async optionsListPopoverGetAvailableOptions() { - this.log.debug(`getting available options count from options list`); + public async optionsListPopoverGetAvailableOptions(filterOutExists: boolean = true) { + this.log.debug(`getting available options from options list`); const availableOptions = await this.testSubjects.find(`optionsList-control-available-options`); - return (await availableOptions.getVisibleText()).split('\n'); + const availableOptionsArray = (await availableOptions.getVisibleText()).split('\n'); + return filterOutExists ? availableOptionsArray.slice(1) : availableOptionsArray; } public async optionsListPopoverSearchForOption(search: string) { diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index 0669bb6e91e52..4067c2f1868c5 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -178,6 +178,22 @@ export class PieChartService extends FtrService { ); } + async getPieChartValues(isNewLibrary: boolean = true) { + this.log.debug('PieChart.getPieChartValues'); + if (isNewLibrary) { + const slices = + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices.map((slice) => { + return slice.value; + }); + } + const chartTypes = await this.find.allByCssSelector('path.slice', this.defaultFindTimeout * 2); + return await Promise.all( + chartTypes.map(async (chart) => await chart.getAttribute('data-value')) + ); + } + async getPieSliceCount(isNewLibrary: boolean = true) { this.log.debug('PieChart.getPieSliceCount'); if (isNewLibrary) {