diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index eb1f7b886d427..8835c7f5767f6 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -40,6 +40,7 @@ export interface OptionsListSuggestions { * The Options list response is returned from the serverside Options List route. */ export interface OptionsListResponse { + rejected: boolean; suggestions: OptionsListSuggestions; totalCardinality: number; invalidSelections?: string[]; diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx index 02a13125ba49c..7f091abe34a56 100644 --- a/src/plugins/controls/public/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -61,6 +61,7 @@ const storybookStubOptionsListRequest = async ( {} ), totalCardinality: 100, + rejected: false, }), 120 ) 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 c737dd6dc0215..e88208ee4c623 100644 --- a/src/plugins/controls/public/options_list/components/options_list.scss +++ b/src/plugins/controls/public/options_list/components/options_list.scss @@ -7,15 +7,6 @@ height: 100%; } -.optionsList__items { - @include euiScrollBar; - - overflow-y: auto; - max-height: $euiSize * 30; - width: $euiSize * 25; - max-width: 100%; -} - .optionsList__actions { padding: $euiSizeS; border-bottom: $euiBorderThin; 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 43742a817e3ec..3035eada20caf 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 @@ -43,18 +43,22 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub const existsSelected = select((state) => state.explicitInput.existsSelected); const controlStyle = select((state) => state.explicitInput.controlStyle); const singleSelect = select((state) => state.explicitInput.singleSelect); + const fieldName = select((state) => state.explicitInput.fieldName); const exclude = select((state) => state.explicitInput.exclude); const id = select((state) => state.explicitInput.id); const loading = select((state) => state.output.loading); // debounce loading state so loading doesn't flash when user types - const [buttonLoading, setButtonLoading] = useState(true); - const debounceSetButtonLoading = useMemo( - () => debounce((latestLoading: boolean) => setButtonLoading(latestLoading), 100), + const [debouncedLoading, setDebouncedLoading] = useState(true); + const debounceSetLoading = useMemo( + () => + debounce((latestLoading: boolean) => { + setDebouncedLoading(latestLoading); + }, 100), [] ); - useEffect(() => debounceSetButtonLoading(loading ?? false), [loading, debounceSetButtonLoading]); + useEffect(() => debounceSetLoading(loading ?? false), [loading, debounceSetLoading]); // remove all other selections if this control is single select useEffect(() => { @@ -111,7 +115,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
setIsPopoverOpen(false)} anchorClassName="optionsList__anchorOverride" - aria-labelledby={`control-popover-${id}`} + aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)} > - + ); 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 b1315be51ae1e..acb5d24d80659 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 @@ -21,6 +21,7 @@ import { ControlOutput, OptionsListEmbeddableInput } from '../..'; describe('Options list popover', () => { const defaultProps = { width: 500, + isLoading: false, updateSearchString: jest.fn(), }; @@ -56,13 +57,13 @@ describe('Options list popover', () => { 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;'); + let popoverDiv = findTestSubject(popover, 'optionsList-control-popover'); + expect(popoverDiv.getDOMNode().getAttribute('style')).toBe('width: 301px;'); // the div cannot be smaller than 301 pixels wide popover = await mountComponent({ popoverProps: { width: 300 } }); - availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - expect(availableOptionsDiv.getDOMNode().getAttribute('style')).toBe(null); + popoverDiv = findTestSubject(popover, 'optionsList-control-available-options'); + expect(popoverDiv.getDOMNode().getAttribute('style')).toBe(null); }); test('no available options', async () => { @@ -92,13 +93,12 @@ describe('Options list popover', () => { explicitInput: { selectedOptions: selections }, }); clickShowOnlySelections(popover); - const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - availableOptionsDiv - .childAt(0) - .children() - .forEach((child, i) => { - expect(child.text()).toBe(selections[i]); - }); + const availableOptions = popover.find( + '[data-test-subj="optionsList-control-available-options"] ul' + ); + availableOptions.children().forEach((child, i) => { + expect(child.text()).toBe(`${selections[i]} - Checked option.`); + }); }); test('disable search and sort when show only selected toggle is true', async () => { @@ -132,11 +132,18 @@ describe('Options list popover', () => { }, }); const validSelection = findTestSubject(popover, 'optionsList-control-selection-bark'); - expect(validSelection.text()).toEqual('bark75'); + expect(validSelection.find('.euiSelectableListItem__text').text()).toEqual( + 'bark - Checked option.' + ); + expect( + validSelection.find('div[data-test-subj="optionsList-document-count-badge"]').text().trim() + ).toEqual('75'); const title = findTestSubject(popover, 'optionList__ignoredSelectionLabel').text(); expect(title).toEqual('Ignored selection'); const invalidSelection = findTestSubject(popover, 'optionsList-control-ignored-selection-woof'); - expect(invalidSelection.text()).toEqual('woof'); + expect(invalidSelection.find('.euiSelectableListItem__text').text()).toEqual( + 'woof - Checked option.' + ); expect(invalidSelection.hasClass('optionsList__selectionInvalid')).toBe(true); }); @@ -221,8 +228,10 @@ describe('Options list popover', () => { explicitInput: { existsSelected: true }, }); clickShowOnlySelections(popover); - const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); - expect(availableOptionsDiv.children().at(0).text()).toBe('Exists'); + const availableOptions = popover.find( + '[data-test-subj="optionsList-control-available-options"] ul' + ); + expect(availableOptions.text()).toBe('Exists - Checked option.'); }); test('when sorting suggestions, show both sorting types for keyword field', async () => { 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 6ad39e0b3dbd9..70353524068cd 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 @@ -22,10 +22,15 @@ import { OptionsListPopoverInvalidSelections } from './options_list_popover_inva export interface OptionsListPopoverProps { width: number; + isLoading: boolean; updateSearchString: (newSearchString: string) => void; } -export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPopoverProps) => { +export const OptionsListPopover = ({ + width, + isLoading, + updateSearchString, +}: OptionsListPopoverProps) => { // Redux embeddable container Context const { useEmbeddableSelector: select } = useReduxEmbeddableContext< OptionsListReduxState, @@ -45,9 +50,10 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop const [showOnlySelected, setShowOnlySelected] = useState(false); return ( - 300 ? width : undefined }} + data-test-subj={`optionsList-control-popover`} aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)} > {title} @@ -59,17 +65,15 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop /> )}
300 ? width : undefined }} data-test-subj={`optionsList-control-available-options`} - data-option-count={Object.keys(availableOptions ?? {}).length} + data-option-count={isLoading ? 0 : Object.keys(availableOptions ?? {}).length} > - + {!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && ( )}
{!hideExclude && } -
+
); }; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx new file mode 100644 index 0000000000000..69c819a3caca2 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_empty_message.tsx @@ -0,0 +1,35 @@ +/* + * 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 { EuiIcon, EuiSpacer } from '@elastic/eui'; + +import { OptionsListStrings } from './options_list_strings'; + +export const OptionsListPopoverEmptyMessage = ({ + showOnlySelected, +}: { + showOnlySelected: boolean; +}) => { + return ( + + + + + {showOnlySelected + ? OptionsListStrings.popover.getSelectionsEmptyMessage() + : OptionsListStrings.popover.getEmptyMessage()} + + + ); +}; 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 index 01c9f14363a4c..424ae37da4bcb 100644 --- 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 @@ -6,9 +6,15 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; -import { EuiFilterSelectItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { + EuiSelectableOption, + EuiSelectable, + EuiSpacer, + EuiTitle, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { OptionsListReduxState } from '../types'; @@ -26,6 +32,31 @@ export const OptionsListPopoverInvalidSelections = () => { // Select current state from Redux using multiple selectors to avoid rerenders. const invalidSelections = select((state) => state.componentState.invalidSelections); + const fieldName = select((state) => state.explicitInput.fieldName); + + const [selectableOptions, setSelectableOptions] = useState([]); // will be set in following useEffect + useEffect(() => { + /* This useEffect makes selectableOptions responsive to unchecking options */ + const options: EuiSelectableOption[] = (invalidSelections ?? []).map((key) => { + return { + key, + label: key, + checked: 'on', + className: 'optionsList__selectionInvalid', + 'data-test-subj': `optionsList-control-ignored-selection-${key}`, + prepend: ( + +
+ {OptionsListStrings.popover.getInvalidSelectionScreenReaderText()} + {'" "'} {/* Adds a pause for the screen reader */} +
+
+ ), + }; + }); + setSelectableOptions(options); + }, [invalidSelections]); + return ( <> @@ -40,18 +71,20 @@ export const OptionsListPopoverInvalidSelections = () => { )} - {invalidSelections?.map((ignoredSelection, index) => ( - dispatch(deselectOption(ignoredSelection))} - aria-label={OptionsListStrings.popover.getInvalidSelectionAriaLabel(ignoredSelection)} - > - {`${ignoredSelection}`} - - ))} + { + setSelectableOptions(newSuggestions); + dispatch(deselectOption(changedOption.label)); + }} + > + {(list) => list} + ); }; diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_suggestion_badge.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_suggestion_badge.tsx new file mode 100644 index 0000000000000..6c50d92ba81b5 --- /dev/null +++ b/src/plugins/controls/public/options_list/components/options_list_popover_suggestion_badge.tsx @@ -0,0 +1,46 @@ +/* + * 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 { css } from '@emotion/react'; +import { EuiScreenReaderOnly, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; + +import { OptionsListStrings } from './options_list_strings'; + +export const OptionsListPopoverSuggestionBadge = ({ documentCount }: { documentCount: number }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <> + + + {`${documentCount.toLocaleString()}`} + + + +
+ {'" "'} {/* Adds a pause for the screen reader */} + {OptionsListStrings.popover.getDocumentCountScreenReaderText(documentCount)} +
+
+ + ); +}; 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 index 7983043ae1d8a..8bd8e361e7081 100644 --- 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 @@ -6,30 +6,25 @@ * Side Public License, v 1. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; -import { - EuiFilterSelectItem, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiSpacer, - EuiIcon, - useEuiTheme, - EuiText, -} from '@elastic/eui'; -import { css } from '@emotion/react'; +import { EuiLoadingSpinner, EuiSelectable, EuiSpacer } from '@elastic/eui'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; +import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; import { OptionsListReduxState } from '../types'; import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from '../options_list_reducers'; +import { OptionsListPopoverEmptyMessage } from './options_list_popover_empty_message'; +import { OptionsListPopoverSuggestionBadge } from './options_list_popover_suggestion_badge'; interface OptionsListPopoverSuggestionsProps { + isLoading: boolean; showOnlySelected: boolean; } export const OptionsListPopoverSuggestions = ({ + isLoading, showOnlySelected, }: OptionsListPopoverSuggestionsProps) => { // Redux embeddable container Context @@ -39,7 +34,6 @@ export const OptionsListPopoverSuggestions = ({ actions: { replaceSelection, deselectOption, selectOption, selectExists }, } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { euiTheme } = useEuiTheme(); // Select current state from Redux using multiple selectors to avoid rerenders. const invalidSelections = select((state) => state.componentState.invalidSelections); @@ -49,130 +43,97 @@ export const OptionsListPopoverSuggestions = ({ const existsSelected = select((state) => state.explicitInput.existsSelected); const singleSelect = select((state) => state.explicitInput.singleSelect); const hideExists = select((state) => state.explicitInput.hideExists); + const fieldName = select((state) => state.explicitInput.fieldName); - 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 : Object.keys(availableOptions ?? {}); - if ( - !loading && - (!suggestions || suggestions.length === 0) && - !(showOnlySelected && existsSelected) - ) { - return ( -
-
- - -

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

-
-
- ); - } + const suggestions = useMemo(() => { + return showOnlySelected ? selectedOptions : Object.keys(availableOptions ?? {}); + }, [availableOptions, selectedOptions, showOnlySelected]); + + const existsSelectableOption = useMemo(() => { + if (hideExists || (!existsSelected && (showOnlySelected || suggestions?.length === 0))) return; + + return { + key: 'exists-option', + checked: existsSelected ? 'on' : undefined, + label: OptionsListStrings.controlAndPopover.getExists(), + className: 'optionsList__existsFilter', + 'data-test-subj': 'optionsList-control-selection-exists', + }; + }, [suggestions, existsSelected, showOnlySelected, hideExists]); + + const [selectableOptions, setSelectableOptions] = useState([]); // will be set in following useEffect + useEffect(() => { + /* This useEffect makes selectableOptions responsive to search, show only selected, and clear selections */ + const options: EuiSelectableOption[] = (suggestions ?? []).map((key) => { + return { + key, + label: key, + checked: selectedOptionsSet?.has(key) ? 'on' : undefined, + 'data-test-subj': `optionsList-control-selection-${key}`, + className: + showOnlySelected && invalidSelectionsSet.has(key) + ? 'optionsList__selectionInvalid' + : 'optionsList__validSuggestion', + append: + !showOnlySelected && availableOptions?.[key] ? ( + + ) : undefined, + }; + }); + const suggestionsSelectableOptions = existsSelectableOption + ? [existsSelectableOption, ...options] + : options; + setSelectableOptions(suggestionsSelectableOptions); + }, [ + suggestions, + availableOptions, + showOnlySelected, + selectedOptionsSet, + invalidSelectionsSet, + existsSelectableOption, + ]); return ( - <> - {!hideExists && !(showOnlySelected && !existsSelected) && ( - { - dispatch(selectExists(!Boolean(existsSelected))); - }} - className="optionsList__existsFilter" - > - {OptionsListStrings.controlAndPopover.getExists()} - + + + + {OptionsListStrings.popover.getLoadingMessage()} + + } + options={selectableOptions} + listProps={{ onFocusBadge: false }} + aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel( + fieldName, + selectableOptions.length )} - {suggestions?.map((key: string) => ( - { - if (showOnlySelected) { - dispatch(deselectOption(key)); - return; - } - if (singleSelect) { - dispatch(replaceSelection(key)); - return; - } - if (selectedOptionsSet.has(key)) { - dispatch(deselectOption(key)); - return; - } - dispatch(selectOption(key)); - }} - className={ - showOnlySelected && invalidSelectionsSet.has(key) - ? 'optionsList__selectionInvalid' - : 'optionsList__validSuggestion' - } - aria-label={ - availableOptions?.[key] - ? OptionsListStrings.popover.getSuggestionAriaLabel( - key, - availableOptions[key].doc_count ?? 0 - ) - : key - } - > - - - {`${key}`} - - {!showOnlySelected && ( - - {availableOptions && availableOptions[key] && ( - - - {`${availableOptions[key].doc_count.toLocaleString()}`} - - - )} - - )} - - - ))} - + emptyMessage={} + onChange={(newSuggestions, _, changedOption) => { + setSelectableOptions(newSuggestions); + + const key = changedOption.key ?? changedOption.label; + // the order of these checks matters, so be careful if rearranging them + if (key === 'exists-option') { + dispatch(selectExists(!Boolean(existsSelected))); + } else if (showOnlySelected || selectedOptionsSet.has(key)) { + dispatch(deselectOption(key)); + } else if (singleSelect) { + dispatch(replaceSelection(key)); + } else { + dispatch(selectOption(key)); + } + }} + > + {(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 a75eb7913064c..bef8a2cbc26ff 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 @@ -48,11 +48,15 @@ export const OptionsListStrings = { defaultMessage: 'Popover for {fieldName} control', values: { fieldName }, }), - getSuggestionAriaLabel: (key: string, documentCount: number) => + getSuggestionsAriaLabel: (fieldName: string, optionCount: number) => i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', { defaultMessage: - '{key}, which appears in {documentCount} {documentCount, plural, one {document} other {documents}}.', - values: { key, documentCount }, + 'Available {optionCount, plural, one {option} other {options}} for {fieldName}', + values: { fieldName, optionCount }, + }), + getLoadingMessage: () => + i18n.translate('controls.optionsList.popover.loading', { + defaultMessage: 'Loading options', }), getEmptyMessage: () => i18n.translate('controls.optionsList.popover.empty', { @@ -80,6 +84,12 @@ export const OptionsListStrings = { 'Search {totalOptions} available {totalOptions, plural, one {option} other {options}}', values: { totalOptions }, }), + getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) => + i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', { + defaultMessage: + 'Ignored {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName}', + values: { fieldName, invalidSelectionCount }, + }), getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) => i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', { defaultMessage: @@ -92,10 +102,9 @@ export const OptionsListStrings = { '{selectedOptions} selected {selectedOptions, plural, one {option} other {options}} {selectedOptions, plural, one {is} other {are}} ignored because {selectedOptions, plural, one {it is} other {they are}} no longer in the data.', values: { selectedOptions }, }), - getInvalidSelectionAriaLabel: (option: string) => - i18n.translate('controls.optionsList.popover.invalidSelectionAriaLabel', { - defaultMessage: 'Ignored selection: {option}', - values: { option }, + getInvalidSelectionScreenReaderText: () => + i18n.translate('controls.optionsList.popover.invalidSelectionScreenReaderText', { + defaultMessage: 'Invalid selection.', }), getIncludeLabel: () => i18n.translate('controls.optionsList.popover.includeLabel', { @@ -127,6 +136,12 @@ export const OptionsListStrings = { 'This value appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', values: { documentCount }, }), + getDocumentCountScreenReaderText: (documentCount: number) => + i18n.translate('controls.optionsList.popover.documentCountScreenReaderText', { + defaultMessage: + 'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', + values: { documentCount }, + }), }, controlAndPopover: { getExists: (negate: number = +false) => 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 1153150143f89..2f710a56c4f87 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 @@ -307,7 +307,7 @@ export class OptionsListEmbeddable extends Embeddable ) => { state.explicitInput.selectedOptions = [action.payload]; + if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false; }, clearSelections: (state: WritableDraft) => { if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false; diff --git a/src/plugins/controls/public/services/options_list/options_list.story.ts b/src/plugins/controls/public/services/options_list/options_list.story.ts index a44f698c93395..62686feee7495 100644 --- a/src/plugins/controls/public/services/options_list/options_list.story.ts +++ b/src/plugins/controls/public/services/options_list/options_list.story.ts @@ -20,6 +20,7 @@ let optionsListRequestMethod = async (request: OptionsListRequest, abortSignal: r({ suggestions: {}, totalCardinality: 100, + rejected: false, }), 120 ) 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 bc2934e9295a6..ab8e67666140b 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 @@ -101,7 +101,7 @@ class OptionsListService implements ControlsOptionsListService { } catch (error) { // Remove rejected results from memoize cache this.cachedOptionsListRequest.cache.delete(this.optionsListCacheResolver(request)); - return {} as OptionsListResponse; + return { rejected: true } as OptionsListResponse; } }; } 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 6e2f8f769815d..0893d24ebacf0 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 @@ -144,6 +144,7 @@ export const setupOptionsListSuggestionsRoute = ( suggestions, totalCardinality, invalidSelections, + rejected: false, }; }; }; 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 b70404f1338bf..4fc9101ed67a5 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -196,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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.optionsListPopoverSelectExists(); await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); await dashboard.waitForRenderComplete(); diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts deleted file mode 100644 index 8186d9702ae3e..0000000000000 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ /dev/null @@ -1,783 +0,0 @@ -/* - * 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 { pick } from 'lodash'; - -import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; -import expect from '@kbn/expect'; - -import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../page_objects/dashboard_page_controls'; -import { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const queryBar = getService('queryBar'); - const pieChart = getService('pieChart'); - const security = getService('security'); - const elasticChart = getService('elasticChart'); - const filterBar = getService('filterBar'); - const testSubjects = getService('testSubjects'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const dashboardPanelActions = getService('dashboardPanelActions'); - - const { dashboardControls, timePicker, console, common, dashboard, header, settings } = - getPageObjects([ - 'dashboardControls', - 'timePicker', - 'dashboard', - 'settings', - 'console', - 'common', - 'header', - ]); - - const DASHBOARD_NAME = 'Test Options List Control'; - - describe('Dashboard options list integration', () => { - let controlId: string; - - const returnToDashboard = async () => { - await common.navigateToApp('dashboard'); - await header.waitUntilLoadingHasFinished(); - await elasticChart.setNewChartUiDebugFlag(); - await dashboard.loadSavedDashboard(DASHBOARD_NAME); - if (await dashboard.getIsInViewMode()) { - await dashboard.switchToEditMode(); - } - await dashboard.waitForRenderComplete(); - }; - - before(async () => { - await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - - await common.navigateToApp('dashboard'); - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await elasticChart.setNewChartUiDebugFlag(); - await dashboard.saveDashboard(DASHBOARD_NAME, { - exitFromEditMode: false, - storeTimeWithDashboard: true, - }); - }); - - describe('Options List Control Editor selects relevant data views', async () => { - it('selects the default data view when the dashboard is blank', async () => { - expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( - 'logstash-*' - ); - }); - - it('selects a relevant data view based on the panels on the dashboard', async () => { - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboard.waitForRenderComplete(); - expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( - 'animals-*' - ); - await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie'); - await dashboard.waitForRenderComplete(); - expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( - 'logstash-*' - ); - }); - - it('selects the last used data view by default', async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( - 'animals-*' - ); - await dashboardControls.deleteAllControls(); - }); - }); - - // Skip on cloud until issue is fixed - // Issue: https://github.com/elastic/kibana/issues/141280 - describe('Options List Control creation and editing experience', function () { - this.tags(['skipCloudFailedTest']); - it('can add a new options list control from a blank state', async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'logstash-*', - fieldName: 'machine.os.raw', - }); - expect(await dashboardControls.getControlsCount()).to.be(1); - await dashboard.clearUnsavedChanges(); - }); - - it('can add a second options list control with a non-default data view', async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - expect(await dashboardControls.getControlsCount()).to.be(2); - - // data views should be properly propagated from the control group to the dashboard - expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); - await dashboard.clearUnsavedChanges(); - }); - - it('renames an existing control', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - - const newTitle = 'wow! Animal sounds?'; - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlEditorSetTitle(newTitle); - await dashboardControls.controlEditorSave(); - expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); - await dashboard.clearUnsavedChanges(); - }); - - it('can change the data view and field of an existing options list', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(firstId); - - const saveButton = await testSubjects.find('control-editor-save'); - expect(await saveButton.isEnabled()).to.be(true); - await dashboardControls.controlsEditorSetDataView('animals-*'); - expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); - await dashboardControls.controlEditorSave(); - - // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await retry.try(async () => { - await testSubjects.click('addFilter'); - const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); - expect(indexPatternSelectExists).to.be(false); - }); - await dashboard.clearUnsavedChanges(); - }); - - it('editing field clears selections', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - await dashboardControls.optionsListOpenPopover(secondId); - await dashboardControls.optionsListPopoverSelectOption('hiss'); - await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); - - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); - await dashboardControls.controlEditorSave(); - - const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); - expect(selectionString).to.be('Any'); - }); - - it('editing other control settings keeps selections', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - await dashboardControls.optionsListOpenPopover(secondId); - await dashboardControls.optionsListPopoverSelectOption('dog'); - await dashboardControls.optionsListPopoverSelectOption('cat'); - await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); - - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlEditorSetTitle('Animal'); - await dashboardControls.controlEditorSetWidth('large'); - await dashboardControls.controlEditorSave(); - - const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); - expect(selectionString).to.be('dog, cat'); - }); - - it('deletes an existing control', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - - await dashboardControls.removeExistingControl(firstId); - expect(await dashboardControls.getControlsCount()).to.be(1); - await dashboard.clearUnsavedChanges(); - }); - - it('cannot create options list for scripted field', async () => { - await dashboardControls.openCreateControlFlyout(); - expect(await dashboardControls.optionsListEditorGetCurrentDataView(false)).to.eql( - 'animals-*' - ); - await testSubjects.missingOrFail('field-picker-select-isDog'); - await dashboardControls.controlEditorCancel(true); - }); - - after(async () => { - await dashboardControls.clearAllControls(); - }); - }); - - describe('Options List Control suggestions', async () => { - before(async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - 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); - - const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) - .sort() - .reverse() - .reduce((result, key) => { - return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] }; - }, {}); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { suggestions: sortedSuggestions, invalidSelections: [] }, - true - ); - }); - - it('sort alphabetically - ascending', async () => { - await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'asc' }); - await dashboardControls.optionsListWaitForLoading(controlId); - - const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) - .sort() - .reduce((result, key) => { - return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] }; - }, {}); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { suggestions: sortedSuggestions, invalidSelections: [] }, - true - ); - }); - - it('sort by document count - descending', async () => { - await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' }); - await dashboardControls.optionsListWaitForLoading(controlId); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { - suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, // keys are already sorted descending by doc count - invalidSelections: [], - }, - true - ); - }); - - it('sort by document count - ascending', async () => { - await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'asc' }); - await dashboardControls.optionsListWaitForLoading(controlId); - const sortedSuggestions = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) - .sort(([, docCountA], [, docCountB]) => { - return docCountB - docCountA; - }) - .reduce((result, [key, docCount]) => { - return { ...result, [key]: docCount }; - }, {}); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { suggestions: sortedSuggestions, invalidSelections: [] }, - true - ); - }); - - it('non-default 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 () => { - it('Applies dashboard query to options list control', async () => { - await queryBar.setQuery('animal.keyword : "dog" '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - ]); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: { ...suggestions, grr: suggestions.grr - 1 }, - invalidSelections: [], - }); - await queryBar.setQuery(''); - await queryBar.submitQuery(); - - // using the query hides the time range. Clicking anywhere else shows it again. - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Applies dashboard time range to options list control', async () => { - // set time range to time with no documents - await timePicker.setAbsoluteRange( - 'Jan 1, 2017 @ 00:00:00.000', - 'Jan 1, 2017 @ 00:00:00.000' - ); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await timePicker.setDefaultDataRange(); - }); - - describe('dashboard filters', async () => { - before(async () => { - await filterBar.addFilter({ - field: 'sound.keyword', - operation: 'is one of', - value: ['bark', 'bow ow ow', 'ruff'], - }); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - }); - - it('Applies dashboard filters to options list control', async () => { - const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ - 'ruff', - 'bark', - 'bow ow ow', - ]); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions, - invalidSelections: [], - }); - }); - - it('Does not apply disabled dashboard filters to options list control', async () => { - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, - invalidSelections: [], - }); - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - }); - - it('Negated filters apply to options control', async () => { - await filterBar.toggleFilterNegated('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ - 'hiss', - 'grrr', - 'meow', - 'growl', - 'grr', - ]); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions, - invalidSelections: [], - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - }); - }); - }); - - describe('Selections made in control apply to dashboard', async () => { - it('Shows available options in options list', async () => { - await queryBar.setQuery(''); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, - invalidSelections: [], - }); - }); - - it('Can search options list for available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('meo'); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { - suggestions: { meow: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow }, - invalidSelections: [], - }, - true - ); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can search options list for available options case insensitive', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('MEO'); - await dashboardControls.ensureAvailableOptionsEqual( - controlId, - { - suggestions: { meow: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow }, - invalidSelections: [], - }, - true - ); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can select multiple available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('hiss'); - await dashboardControls.optionsListPopoverSelectOption('grr'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Selected options appear in control', async () => { - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - it('Applies options list control options to dashboard', async () => { - await retry.try(async () => { - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - }); - - it('Applies options list control options to dashboard by default on open', async () => { - await dashboard.gotoDashboardLandingPage(); - await header.waitUntilLoadingHasFinished(); - await dashboard.clickUnsavedChangesContinueEditing(DASHBOARD_NAME); - await header.waitUntilLoadingHasFinished(); - expect(await pieChart.getPieSliceCount()).to.be(2); - - 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.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(); - }); - - it('changes to selections can be discarded', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - let selections = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selections).to.equal('hiss, grr, bark'); - - await dashboard.clickCancelOutOfEditMode(); - selections = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selections).to.equal('hiss, grr'); - }); - - it('dashboard does not load with unsaved changes when changes are discarded', async () => { - await dashboard.switchToEditMode(); - await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); - }); - }); - - describe('test data view runtime field', async () => { - const FIELD_NAME = 'testRuntimeField'; - const FIELD_VALUES = { - G: - OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.growl + - OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.grr + - OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.grrr, - H: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, - B: - OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.bark + - OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS['bow ow ow'], - R: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.ruff, - M: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow, - }; - - before(async () => { - await common.navigateToApp('settings'); - await settings.clickKibanaIndexPatterns(); - await settings.clickIndexPatternByName('animals-*'); - await settings.addRuntimeField( - FIELD_NAME, - 'keyword', - `emit(doc['sound.keyword'].value.substring(0, 1).toUpperCase())` - ); - await header.waitUntilLoadingHasFinished(); - - await returnToDashboard(); - await dashboardControls.deleteAllControls(); - }); - - it('can create options list control on runtime field', async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - fieldName: FIELD_NAME, - dataViewTitle: 'animals-*', - }); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - it('new control has expected suggestions', async () => { - controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: FIELD_VALUES, - invalidSelections: [], - }); - }); - - it('making selection has expected results', async () => { - 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']); - }); - - after(async () => { - await dashboardControls.deleteAllControls(); - await dashboard.clickQuickSave(); - await header.waitUntilLoadingHasFinished(); - - await common.navigateToApp('settings'); - await settings.clickKibanaIndexPatterns(); - await settings.clickIndexPatternByName('animals-*'); - await settings.filterField('testRuntimeField'); - await testSubjects.click('deleteField'); - await settings.confirmDelete(); - }); - }); - - describe('test exists query', async () => { - 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 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"' - ); - await returnToDashboard(); - - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - title: 'Animal', - }); - controlId = (await dashboardControls.getAllControlIds())[0]; - await header.waitUntilLoadingHasFinished(); - await dashboard.waitForRenderComplete(); - }); - - 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 common.navigateToApp('console'); - await console.clearTextArea(); - for (const { index, id } of newDocuments) { - await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); - await console.clickPlay(); - await header.waitUntilLoadingHasFinished(); - } - - await returnToDashboard(); - await dashboardControls.deleteAllControls(); - }); - }); - - describe('Options List dashboard validation', async () => { - before(async () => { - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sounds', - }); - controlId = (await dashboardControls.getAllControlIds())[0]; - - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - after(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await filterBar.removeAllFilters(); - }); - - it('Can mark selections invalid with Query', async () => { - await queryBar.setQuery('NOT animal.keyword : "dog" '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ - 'hiss', - 'meow', - 'growl', - 'grr', - ]); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: { ...suggestions, grr: suggestions.grr - 1 }, - invalidSelections: ['bark'], - }); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - - it('can make invalid selections valid again if the parent filter changes', async () => { - await queryBar.setQuery(''); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, - invalidSelections: [], - }); - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - - it('Can mark multiple selections invalid with Filter', async () => { - await filterBar.addFilter({ field: 'sound.keyword', operation: 'is', value: 'hiss' }); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: { - hiss: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, - }, - invalidSelections: ['meow', 'bark'], - }); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - }); - - describe('Options List dashboard no validation', async () => { - before(async () => { - await filterBar.removeAllFilters(); - await queryBar.clickQuerySubmitButton(); - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await dashboardControls.updateValidationSetting(false); - }); - - it('Does not mark selections invalid with Query', async () => { - await queryBar.setQuery('NOT animal.keyword : "dog" '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ - 'hiss', - 'meow', - 'growl', - 'grr', - ]); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: { ...suggestions, grr: suggestions.grr - 1 }, - invalidSelections: [], - }); - }); - - it('Does not mark multiple selections invalid with Filter', async () => { - await filterBar.addFilter({ field: 'sound.keyword', operation: 'is', value: 'hiss' }); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await dashboardControls.ensureAvailableOptionsEqual(controlId, { - suggestions: { - hiss: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, - }, - invalidSelections: [], - }); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - await queryBar.clickQuerySubmitButton(); - await dashboardControls.clearAllControls(); - }); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - }); - }); -} diff --git a/test/functional/apps/dashboard_elements/controls/options_list/index.ts b/test/functional/apps/dashboard_elements/controls/options_list/index.ts new file mode 100644 index 0000000000000..9d4cb7d18d525 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list/index.ts @@ -0,0 +1,50 @@ +/* + * 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 { FtrProviderContext } from '../../../../ftr_provider_context'; + +export const OPTIONS_LIST_DASHBOARD_NAME = 'Test Options List Control'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const elasticChart = getService('elasticChart'); + const security = getService('security'); + + const { timePicker, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + ]); + + async function setup() { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + await elasticChart.setNewChartUiDebugFlag(); + await dashboard.saveDashboard(OPTIONS_LIST_DASHBOARD_NAME, { + exitFromEditMode: false, + storeTimeWithDashboard: true, + }); + } + + async function teardown() { + await security.testUser.restoreDefaults(); + } + + describe('Options list control', function () { + before(setup); + after(teardown); + + loadTestFile(require.resolve('./options_list_creation_and_editing')); + loadTestFile(require.resolve('./options_list_dashboard_interaction')); + loadTestFile(require.resolve('./options_list_suggestions')); + loadTestFile(require.resolve('./options_list_validation')); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts new file mode 100644 index 0000000000000..2281c5ce112be --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_creation_and_editing.ts @@ -0,0 +1,181 @@ +/* + * 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 { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + const { dashboardControls, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'settings', + 'console', + 'common', + 'header', + ]); + + describe('Dashboard options list creation and editing', () => { + before(async () => { + await dashboard.ensureDashboardIsInEditMode(); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + await dashboard.clickQuickSave(); + }); + + describe('Options List Control Editor selects relevant data views', async () => { + it('selects the default data view when the dashboard is blank', async () => { + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'logstash-*' + ); + }); + + it('selects a relevant data view based on the panels on the dashboard', async () => { + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboard.waitForRenderComplete(); + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'animals-*' + ); + await dashboard.waitForRenderComplete(); + await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie'); + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'logstash-*' + ); + }); + + it('selects the last used data view by default', async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'animals-*' + ); + await dashboardControls.deleteAllControls(); + }); + }); + + // Skip on cloud until issue is fixed + // Issue: https://github.com/elastic/kibana/issues/141280 + describe('Options List Control creation and editing experience', function () { + this.tags(['skipCloudFailedTest']); + it('can add a new options list control from a blank state', async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'logstash-*', + fieldName: 'machine.os.raw', + }); + expect(await dashboardControls.getControlsCount()).to.be(1); + await dashboard.clearUnsavedChanges(); + }); + + it('can add a second options list control with a non-default data view', async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + expect(await dashboardControls.getControlsCount()).to.be(2); + + // data views should be properly propagated from the control group to the dashboard + expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); + await dashboard.clearUnsavedChanges(); + }); + + it('renames an existing control', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + + const newTitle = 'wow! Animal sounds?'; + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle(newTitle); + await dashboardControls.controlEditorSave(); + expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); + await dashboard.clearUnsavedChanges(); + }); + + it('can change the data view and field of an existing options list', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + + const saveButton = await testSubjects.find('control-editor-save'); + expect(await saveButton.isEnabled()).to.be(true); + await dashboardControls.controlsEditorSetDataView('animals-*'); + expect(await saveButton.isEnabled()).to.be(false); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); + await dashboardControls.controlEditorSave(); + + // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); + await dashboard.clearUnsavedChanges(); + }); + + it('editing field clears selections', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + await dashboardControls.optionsListOpenPopover(secondId); + await dashboardControls.optionsListPopoverSelectOption('hiss'); + await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); + + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); + await dashboardControls.controlEditorSave(); + + const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); + expect(selectionString).to.be('Any'); + }); + + it('editing other control settings keeps selections', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + await dashboardControls.optionsListOpenPopover(secondId); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); + + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle('Animal'); + await dashboardControls.controlEditorSetWidth('large'); + await dashboardControls.controlEditorSave(); + + const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); + expect(selectionString).to.be('dog, cat'); + }); + + it('deletes an existing control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + + await dashboardControls.removeExistingControl(firstId); + expect(await dashboardControls.getControlsCount()).to.be(1); + await dashboard.clearUnsavedChanges(); + }); + + it('cannot create options list for scripted field', async () => { + await dashboardControls.openCreateControlFlyout(); + expect(await dashboardControls.optionsListEditorGetCurrentDataView(false)).to.eql( + 'animals-*' + ); + await testSubjects.missingOrFail('field-picker-select-isDog'); + await dashboardControls.controlEditorCancel(true); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_dashboard_interaction.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_dashboard_interaction.ts new file mode 100644 index 0000000000000..43bde4798e3d5 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_dashboard_interaction.ts @@ -0,0 +1,413 @@ +/* + * 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 { pick } from 'lodash'; + +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; +import expect from '@kbn/expect'; + +import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { OPTIONS_LIST_DASHBOARD_NAME } from '.'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const queryBar = getService('queryBar'); + const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + const { dashboardControls, timePicker, console, common, dashboard, header, settings } = + getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'settings', + 'console', + 'common', + 'header', + ]); + + describe('Interactions between options list and dashboard', () => { + let controlId: string; + + const returnToDashboard = async () => { + await common.navigateToApp('dashboard'); + await header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(); + await dashboard.loadSavedDashboard(OPTIONS_LIST_DASHBOARD_NAME); + await dashboard.ensureDashboardIsInEditMode(); + }; + + before(async () => { + await dashboard.ensureDashboardIsInEditMode(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie'); + await dashboard.clickQuickSave(); + }); + + describe('Applies query settings to controls', async () => { + it('Applies dashboard query to options list control', async () => { + await queryBar.setQuery('animal.keyword : "dog" '); + await queryBar.submitQuery(); // quicker than clicking the submit button, but hides the time picker + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + ]); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: { ...suggestions, grr: suggestions.grr - 1 }, + invalidSelections: [], + }); + await queryBar.setQuery(''); + await queryBar.clickQuerySubmitButton(); // ensures that the time picker is visible for the next test + }); + + it('Applies dashboard time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await dashboardControls.optionsListOpenPopover(controlId); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await timePicker.setDefaultDataRange(); + }); + + describe('dashboard filters', async () => { + before(async () => { + await filterBar.addFilter({ + field: 'sound.keyword', + operation: 'is one of', + value: ['bark', 'bow ow ow', 'ruff'], + }); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Applies dashboard filters to options list control', async () => { + const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ + 'ruff', + 'bark', + 'bow ow ow', + ]); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions, + invalidSelections: [], + }); + }); + + it('Does not apply disabled dashboard filters to options list control', async () => { + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, + invalidSelections: [], + }); + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Negated filters apply to options control', async () => { + await filterBar.toggleFilterNegated('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ + 'hiss', + 'grrr', + 'meow', + 'growl', + 'grr', + ]); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions, + invalidSelections: [], + }); + }); + + after(async () => { + await filterBar.removeAllFilters(); + }); + }); + }); + + describe('Selections made in control apply to dashboard', async () => { + it('Shows available options in options list', async () => { + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, + invalidSelections: [], + }); + }); + + it('Can search options list for available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('meo'); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { + suggestions: { meow: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow }, + invalidSelections: [], + }, + true + ); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Can search options list for available options case insensitive', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('MEO'); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { + suggestions: { meow: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow }, + invalidSelections: [], + }, + true + ); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Can select multiple available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('hiss'); + await dashboardControls.optionsListPopoverSelectOption('grr'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Selected options appear in control', async () => { + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + it('Applies options list control options to dashboard', async () => { + await retry.try(async () => { + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + }); + + it('Applies options list control options to dashboard by default on open', async () => { + await dashboard.gotoDashboardLandingPage(); + await header.waitUntilLoadingHasFinished(); + await dashboard.clickUnsavedChangesContinueEditing(OPTIONS_LIST_DASHBOARD_NAME); + await header.waitUntilLoadingHasFinished(); + expect(await pieChart.getPieSliceCount()).to.be(2); + + 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.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(); + }); + + it('changes to selections can be discarded', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + let selections = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selections).to.equal('hiss, grr, bark'); + + await dashboard.clickCancelOutOfEditMode(); + selections = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selections).to.equal('hiss, grr'); + }); + + it('dashboard does not load with unsaved changes when changes are discarded', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); + }); + + describe('Test data view runtime field', async () => { + const FIELD_NAME = 'testRuntimeField'; + const FIELD_VALUES = { + G: + OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.growl + + OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.grr + + OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.grrr, + H: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, + B: + OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.bark + + OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS['bow ow ow'], + R: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.ruff, + M: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.meow, + }; + + before(async () => { + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternByName('animals-*'); + await settings.addRuntimeField( + FIELD_NAME, + 'keyword', + `emit(doc['sound.keyword'].value.substring(0, 1).toUpperCase())` + ); + await header.waitUntilLoadingHasFinished(); + + await returnToDashboard(); + await dashboardControls.deleteAllControls(); + }); + + it('can create options list control on runtime field', async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + fieldName: FIELD_NAME, + dataViewTitle: 'animals-*', + }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + it('new control has expected suggestions', async () => { + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: FIELD_VALUES, + invalidSelections: [], + }); + }); + + it('making selection has expected results', async () => { + 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']); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternByName('animals-*'); + await settings.filterField('testRuntimeField'); + await testSubjects.click('deleteField'); + await settings.confirmDelete(); + }); + }); + + describe('Test exists query', async () => { + 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 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"' + ); + await returnToDashboard(); + + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + }); + + it('creating exists query has expected results', async () => { + expect((await pieChart.getPieChartValues())[0]).to.be(6); + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectExists(); + 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 common.navigateToApp('console'); + await console.clearTextArea(); + for (const { index, id } of newDocuments) { + await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + } + await returnToDashboard(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts new file mode 100644 index 0000000000000..6c69dc99bc4d3 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts @@ -0,0 +1,116 @@ +/* + * 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 { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; + +import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + const { dashboardControls, dashboard, header } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'settings', + 'console', + 'common', + 'header', + ]); + + describe('Dashboard options list suggestions', () => { + let controlId: string; + + before(async () => { + await dashboard.ensureDashboardIsInEditMode(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + + await dashboardControls.optionsListOpenPopover(controlId); + }); + + after(async () => { + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboardControls.deleteAllControls(); + await dashboard.clickQuickSave(); + }); + + it('sort alphabetically - descending', async () => { + await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'desc' }); + const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) + .sort() + .reverse() + .reduce((result, key) => { + return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] }; + }, {}); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { suggestions: sortedSuggestions, invalidSelections: [] }, + true + ); + }); + + it('sort alphabetically - ascending', async () => { + await dashboardControls.optionsListPopoverSetSort({ by: '_key', direction: 'asc' }); + const sortedSuggestions = Object.keys(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) + .sort() + .reduce((result, key) => { + return { ...result, [key]: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS[key] }; + }, {}); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { suggestions: sortedSuggestions, invalidSelections: [] }, + true + ); + }); + + it('sort by document count - descending', async () => { + await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' }); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { + suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, // keys are already sorted descending by doc count + invalidSelections: [], + }, + true + ); + }); + + it('sort by document count - ascending', async () => { + await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'asc' }); + const sortedSuggestions = Object.entries(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS) + .sort(([, docCountA], [, docCountB]) => { + return docCountB - docCountA; + }) + .reduce((result, [key, docCount]) => { + return { ...result, [key]: docCount }; + }, {}); + await dashboardControls.ensureAvailableOptionsEqual( + controlId, + { suggestions: sortedSuggestions, invalidSelections: [] }, + true + ); + }); + + it('non-default sort value should cause unsaved changes', async () => { + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); + + it('returning to default sort value should remove unsaved changes', async () => { + await dashboardControls.optionsListPopoverSetSort({ by: '_count', direction: 'desc' }); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts new file mode 100644 index 0000000000000..f48bc5d9e4c42 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts @@ -0,0 +1,161 @@ +/* + * 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 { pick } from 'lodash'; + +import expect from '@kbn/expect'; +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; + +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const queryBar = getService('queryBar'); + const pieChart = getService('pieChart'); + const filterBar = getService('filterBar'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + const { dashboardControls, dashboard, header } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'settings', + 'console', + 'common', + 'header', + ]); + + describe('Dashboard options list validation', () => { + let controlId: string; + + before(async () => { + await dashboard.ensureDashboardIsInEditMode(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await filterBar.removeAllFilters(); + await dashboardControls.deleteAllControls(); + await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie'); + await dashboard.clickQuickSave(); + }); + + describe('Options List dashboard validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); + }); + + it('Can mark selections invalid with Query', async () => { + await queryBar.setQuery('NOT animal.keyword : "dog" '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ + 'hiss', + 'meow', + 'growl', + 'grr', + ]); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: { ...suggestions, grr: suggestions.grr - 1 }, + invalidSelections: ['bark'], + }); + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + + it('can make invalid selections valid again if the parent filter changes', async () => { + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, + invalidSelections: [], + }); + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + + it('Can mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter({ field: 'sound.keyword', operation: 'is', value: 'hiss' }); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: { + hiss: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, + }, + invalidSelections: ['meow', 'bark'], + }); + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + }); + + describe('Options List dashboard no validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboardControls.updateValidationSetting(false); + }); + + it('Does not mark selections invalid with Query', async () => { + await queryBar.setQuery('NOT animal.keyword : "dog" '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [ + 'hiss', + 'meow', + 'growl', + 'grr', + ]); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: { ...suggestions, grr: suggestions.grr - 1 }, + invalidSelections: [], + }); + }); + + it('Does not mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter({ field: 'sound.keyword', operation: 'is', value: 'hiss' }); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await dashboardControls.ensureAvailableOptionsEqual(controlId, { + suggestions: { + hiss: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS.hiss, + }, + invalidSelections: [], + }); + }); + }); + }); +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 0f5a6d41e6fe0..1f9656c9ff548 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -281,6 +281,13 @@ export class DashboardPageObject extends FtrService { return await this.testSubjects.exists('dashboardEditMode'); } + public async ensureDashboardIsInEditMode() { + if (await this.getIsInViewMode()) { + await this.switchToEditMode(); + } + await this.waitForRenderComplete(); + } + public async clickCancelOutOfEditMode(accept = true) { this.log.debug('clickCancelOutOfEditMode'); if (await this.getIsInViewMode()) return; diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index cdb48b067aec5..85908bce3d35c 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -14,8 +14,8 @@ import { } from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { OptionsListSortingType } from '@kbn/controls-plugin/common/options_list/suggestions_sorting'; -import { WebElementWrapper } from '../services/lib/web_element_wrapper'; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { @@ -48,6 +48,7 @@ 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 browser = this.ctx.getService('browser'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly common = this.ctx.getPageObject('common'); @@ -354,6 +355,7 @@ export class DashboardPageControls extends FtrService { public async optionsListGetSelectionsString(controlId: string) { this.log.debug(`Getting selections string for Options List: ${controlId}`); + await this.optionsListWaitForLoading(controlId); const controlElement = await this.getControlElementById(controlId); return (await controlElement.getVisibleText()).split('\n')[1]; } @@ -362,7 +364,7 @@ export class DashboardPageControls extends FtrService { this.log.debug(`Opening popover for Options List: ${controlId}`); await this.testSubjects.click(`optionsList-control-${controlId}`); await this.retry.try(async () => { - await this.testSubjects.existOrFail(`optionsList-control-available-options`); + await this.testSubjects.existOrFail(`optionsList-control-popover`); }); } @@ -382,25 +384,27 @@ export class DashboardPageControls extends FtrService { public async optionsListPopoverGetAvailableOptionsCount() { this.log.debug(`getting available options count from options list`); + await this.optionsListPopoverWaitForLoading(); const availableOptions = await this.testSubjects.find(`optionsList-control-available-options`); return +(await availableOptions.getAttribute('data-option-count')); } public async optionsListPopoverGetAvailableOptions() { this.log.debug(`getting available options from options list`); + await this.optionsListPopoverWaitForLoading(); const availableOptions = await this.testSubjects.find(`optionsList-control-available-options`); - - const suggestionElements = await availableOptions.findAllByClassName( - 'optionsList__validSuggestion' - ); - const suggestions: { [key: string]: number } = await suggestionElements.reduce( - async (promise, option) => { - const acc = await promise; - const [key, docCount] = (await option.getVisibleText()).split('\n'); - return { ...acc, [key]: Number(docCount) }; - }, - Promise.resolve({} as { [key: string]: number }) - ); + const optionsCount = await this.optionsListPopoverGetAvailableOptionsCount(); + + const selectableListItems = await availableOptions.findByClassName('euiSelectableList__list'); + const suggestions: { [key: string]: number } = {}; + while (Object.keys(suggestions).length < optionsCount) { + await selectableListItems._webElement.sendKeys(this.browser.keys.ARROW_DOWN); + const currentOption = await selectableListItems.findByCssSelector('[aria-selected="true"]'); + const [suggestion, docCount] = (await currentOption.getVisibleText()).split('\n'); + if (suggestion !== 'Exists') { + suggestions[suggestion] = Number(docCount); + } + } const invalidSelectionElements = await availableOptions.findAllByClassName( 'optionsList__selectionInvalid' @@ -419,6 +423,7 @@ export class DashboardPageControls extends FtrService { expectation: { suggestions: { [key: string]: number }; invalidSelections: string[] }, skipOpen?: boolean ) { + await this.optionsListWaitForLoading(controlId); if (!skipOpen) await this.optionsListOpenPopover(controlId); await this.retry.try(async () => { expect(await this.optionsListPopoverGetAvailableOptions()).to.eql(expectation); @@ -456,10 +461,25 @@ export class DashboardPageControls extends FtrService { }); } + public async optionsListPopoverSelectExists() { + await this.retry.try(async () => { + await this.testSubjects.existOrFail(`optionsList-control-selection-exists`); + await this.testSubjects.click(`optionsList-control-selection-exists`); + }); + } + public async optionsListPopoverSelectOption(availableOption: string) { this.log.debug(`selecting ${availableOption} from options list`); - await this.optionsListPopoverAssertOpen(); - await this.testSubjects.click(`optionsList-control-selection-${availableOption}`); + await this.optionsListPopoverSearchForOption(availableOption); + await this.optionsListPopoverWaitForLoading(); + + await this.retry.try(async () => { + await this.testSubjects.existOrFail(`optionsList-control-selection-${availableOption}`); + await this.testSubjects.click(`optionsList-control-selection-${availableOption}`); + }); + + await this.optionsListPopoverClearSearch(); + await this.optionsListPopoverWaitForLoading(); } public async optionsListPopoverClearSelections() { @@ -482,9 +502,16 @@ export class DashboardPageControls extends FtrService { } public async optionsListWaitForLoading(controlId: string) { + this.log.debug(`wait for ${controlId} to load`); await this.testSubjects.waitForEnabled(`optionsList-control-${controlId}`); } + public async optionsListPopoverWaitForLoading() { + this.log.debug(`wait for the suggestions in the popover to load`); + await this.optionsListPopoverAssertOpen(); + await this.testSubjects.waitForDeleted('optionsList-control-popover-loading'); + } + /* ----------------------------------------------------------- Control editor flyout ----------------------------------------------------------- */