[Dashboard] [Controls] Add "Exists" functionality to options list (#143762)

* Mock first attempt at UI

* Add `exists` filter functionality

* Make `exists` selection change button

* Overwrite selections instead of disabling them

* Add support for negate to exists

* Add to diffing system

* Add toggle to disable `exists` query

* Clear `exists` selection when toggle is disabled + fix mocks

* Switch to tooltip instead of docs link

* Clean up popover logic

* Fix rendering through memoization

* Auto focus on search when popover opens

* Added Jest unit tests

* Beef up mock and add more Jest unit tests

* Add functional tests

* Split up popover in to smaller components

* Fix unit tests + functional test flakiness

* Fix flakiness a second time + add chaining tests

* Clean up code

* Add `exists` selection to validation

* Fix invalid bug

* Fix failing unit test

* More code clean up

* Add another functional test

* Apply styling changes

* Fix tests

* Fix a11y issues

* Remove validation

* Fix types

* Clean up `a11y` fix

* Fix jest test

* Address feedback

* Fix wording of tooltip
This commit is contained in:
Hannah Mudge 2022-11-04 15:08:07 -06:00 committed by GitHub
parent 88cfa2a4dd
commit a30e6c2555
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 938 additions and 392 deletions

View file

@ -33,25 +33,31 @@ export const ControlPanelDiffSystems: {
const {
exclude: excludeA,
hideExists: hideExistsA,
hideExclude: hideExcludeA,
selectedOptions: selectedA,
singleSelect: singleSelectA,
hideExclude: hideExcludeA,
existsSelected: existsSelectedA,
runPastTimeout: runPastTimeoutA,
...inputA
}: Partial<OptionsListEmbeddableInput> = initialInput.explicitInput;
const {
exclude: excludeB,
hideExists: hideExistsB,
hideExclude: hideExcludeB,
selectedOptions: selectedB,
singleSelect: singleSelectB,
hideExclude: hideExcludeB,
existsSelected: existsSelectedB,
runPastTimeout: runPastTimeoutB,
...inputB
}: Partial<OptionsListEmbeddableInput> = newInput.explicitInput;
return (
Boolean(excludeA) === Boolean(excludeB) &&
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(hideExistsA) === Boolean(hideExistsB) &&
Boolean(hideExcludeA) === Boolean(hideExcludeB) &&
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
isEqual(selectedA ?? [], selectedB ?? []) &&
deepEqual(inputA, inputB)

View file

@ -6,10 +6,12 @@
* Side Public License, v 1.
*/
import { ReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public/redux_embeddables/types';
import { ControlOutput } from '../../public/types';
import { createReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public/redux_embeddables/create_redux_embeddable_tools';
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public';
import { OptionsListComponentState, OptionsListReduxState } from '../../public/options_list/types';
import { optionsListReducers } from '../../public/options_list/options_list_reducers';
import { ControlFactory, ControlOutput } from '../../public/types';
import { OptionsListEmbeddableInput } from './types';
const mockOptionsListComponentState = {
@ -36,27 +38,26 @@ const mockOptionsListOutput = {
loading: false,
} as ControlOutput;
export const mockOptionsListContext = (
export const mockOptionsListReduxEmbeddableTools = async (
partialState?: Partial<OptionsListReduxState>
): ReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers> => {
const mockReduxState = {
componentState: {
) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();
const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});
const mockEmbeddable = (await optionsListControlFactory.create({
...mockOptionsListEmbeddableInput,
...partialState?.explicitInput,
})) as OptionsListEmbeddable;
mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockOptionsListOutput);
const mockReduxEmbeddableTools = createReduxEmbeddableTools<OptionsListReduxState>({
embeddable: mockEmbeddable,
reducers: optionsListReducers,
initialComponentState: {
...mockOptionsListComponentState,
...partialState?.componentState,
},
explicitInput: {
...mockOptionsListEmbeddableInput,
...partialState?.explicitInput,
},
output: {
...mockOptionsListOutput,
...partialState?.output,
},
} as OptionsListReduxState;
});
return {
actions: {},
useEmbeddableDispatch: () => {},
useEmbeddableSelector: (selector: any) => selector(mockReduxState),
} as unknown as ReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>;
return mockReduxEmbeddableTools;
};

View file

@ -15,9 +15,11 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl';
export interface OptionsListEmbeddableInput extends DataControlInput {
selectedOptions?: string[];
existsSelected?: boolean;
runPastTimeout?: boolean;
singleSelect?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
exclude?: boolean;
}

View file

@ -35,6 +35,16 @@
font-weight: 300;
}
.optionsList__existsFilter {
font-style: italic;
}
.optionsList__negateLabel {
font-weight: bold;
font-size: $euiSizeM;
color: $euiColorDanger;
}
.optionsList__ignoredBadge {
margin-left: $euiSizeS;
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
import { OptionsListControl } from './options_list_control';
import { BehaviorSubject } from 'rxjs';
describe('Options list control', () => {
const defaultProps = {
typeaheadSubject: new BehaviorSubject(''),
};
interface MountOptions {
componentState: Partial<OptionsListComponentState>;
explicitInput: Partial<OptionsListEmbeddableInput>;
output: Partial<ControlOutput>;
}
async function mountComponent(options?: Partial<MountOptions>) {
const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return mountWithIntl(
<mockReduxEmbeddableTools.Wrapper>
<OptionsListControl {...defaultProps} />
</mockReduxEmbeddableTools.Wrapper>
);
}
test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => {
const control = await mountComponent({
explicitInput: { id: 'testExists', exclude: false, existsSelected: true },
});
const existsOption = findTestSubject(control, 'optionsList-control-testExists');
expect(existsOption.text()).toBe('Exists');
});
test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => {
const control = await mountComponent({
explicitInput: { id: 'testDoesNotExist', exclude: true, existsSelected: true },
});
const existsOption = findTestSubject(control, 'optionsList-control-testDoesNotExist');
expect(existsOption.text()).toBe('DOES NOT Exist');
});
});

View file

@ -11,13 +11,7 @@ import classNames from 'classnames';
import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiTextColor,
useResizeObserver,
} from '@elastic/eui';
import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListStrings } from './options_list_strings';
@ -46,10 +40,11 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
const validSelections = select((state) => state.componentState.validSelections);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const existsSelected = select((state) => state.explicitInput.existsSelected);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const id = select((state) => state.explicitInput.id);
const exclude = select((state) => state.explicitInput.exclude);
const id = select((state) => state.explicitInput.id);
const loading = select((state) => state.output.loading);
@ -83,22 +78,34 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
selectionDisplayNode: (
<>
{exclude && (
<EuiTextColor color="danger">
<b>{OptionsListStrings.control.getNegate()}</b>{' '}
</EuiTextColor>
<>
<span className="optionsList__negateLabel">
{existsSelected
? OptionsListStrings.control.getExcludeExists()
: OptionsListStrings.control.getNegate()}
</span>{' '}
</>
)}
{validSelections && (
<span>{validSelections?.join(OptionsListStrings.control.getSeparator())}</span>
)}
{invalidSelections && (
<span className="optionsList__filterInvalid">
{invalidSelections.join(OptionsListStrings.control.getSeparator())}
{existsSelected ? (
<span className={`optionsList__existsFilter`}>
{OptionsListStrings.controlAndPopover.getExists(+Boolean(exclude))}
</span>
) : (
<>
{validSelections && (
<span>{validSelections?.join(OptionsListStrings.control.getSeparator())}</span>
)}
{invalidSelections && (
<span className="optionsList__filterInvalid">
{invalidSelections.join(OptionsListStrings.control.getSeparator())}
</span>
)}
</>
)}
</>
),
};
}, [exclude, validSelections, invalidSelections]);
}, [exclude, existsSelected, validSelections, invalidSelections]);
const button = (
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>
@ -115,7 +122,9 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
>
{hasSelections ? selectionDisplayNode : OptionsListStrings.control.getPlaceholder()}
{hasSelections || existsSelected
? selectionDisplayNode
: OptionsListStrings.control.getPlaceholder()}
</EuiFilterButton>
</div>
);
@ -136,6 +145,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
className="optionsList__popoverOverride"
closePopover={() => setIsPopoverOpen(false)}
anchorClassName="optionsList__anchorOverride"
aria-labelledby={`control-popover-${id}`}
>
<OptionsListPopover width={dimensions.width} updateSearchString={updateSearchString} />
</EuiPopover>

View file

@ -8,16 +8,28 @@
import React, { useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, EuiSwitch } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiSwitch,
EuiSwitchEvent,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { OptionsListStrings } from './options_list_strings';
import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
interface OptionsListEditorState {
singleSelect?: boolean;
runPastTimeout?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
}
interface SwitchProps {
checked: boolean;
onChange: (event: EuiSwitchEvent) => void;
}
export const OptionsListEditorOptions = ({
@ -28,8 +40,33 @@ export const OptionsListEditorOptions = ({
singleSelect: initialInput?.singleSelect,
runPastTimeout: initialInput?.runPastTimeout,
hideExclude: initialInput?.hideExclude,
hideExists: initialInput?.hideExists,
});
const SwitchWithTooltip = ({
switchProps,
label,
tooltip,
}: {
switchProps: SwitchProps;
label: string;
tooltip: string;
}) => (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiSwitch label={label} {...switchProps} />
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip content={tooltip} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
);
return (
<>
<EuiFormRow>
@ -54,29 +91,31 @@ export const OptionsListEditorOptions = ({
/>
</EuiFormRow>
<EuiFormRow>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiSwitch
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
checked={Boolean(state.runPastTimeout)}
onChange={() => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip
content={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
position="right"
/>
</EuiFlexItem>
</EuiFlexGroup>
<SwitchWithTooltip
label={OptionsListStrings.editor.getHideExistsQueryTitle()}
tooltip={OptionsListStrings.editor.getHideExistsQueryTooltip()}
switchProps={{
checked: !state.hideExists,
onChange: () => {
onChange({ hideExists: !state.hideExists });
setState((s) => ({ ...s, hideExists: !s.hideExists }));
if (initialInput?.existsSelected) onChange({ existsSelected: false });
},
}}
/>
</EuiFormRow>
<EuiFormRow>
<SwitchWithTooltip
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
tooltip={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
switchProps={{
checked: Boolean(state.runPastTimeout),
onChange: () => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
},
}}
/>
</EuiFormRow>
</>
);

View file

@ -11,12 +11,11 @@ import { ReactWrapper } from 'enzyme';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EmbeddableReduxContext } from '@kbn/presentation-util-plugin/public/redux_embeddables/use_redux_embeddable_context';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
import { mockOptionsListContext } from '../../../common/mocks';
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
describe('Options list popover', () => {
const defaultProps = {
@ -31,18 +30,18 @@ describe('Options list popover', () => {
popoverProps: Partial<OptionsListPopoverProps>;
}
function mountComponent(options?: Partial<MountOptions>) {
async function mountComponent(options?: Partial<MountOptions>) {
const compProps = { ...defaultProps, ...(options?.popoverProps ?? {}) };
const context = mockOptionsListContext({
const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return mountWithIntl(
<EmbeddableReduxContext.Provider value={context}>
<mockReduxEmbeddableTools.Wrapper>
<OptionsListPopover {...compProps} />
</EmbeddableReduxContext.Provider>
</mockReduxEmbeddableTools.Wrapper>
);
}
@ -54,19 +53,19 @@ describe('Options list popover', () => {
showOnlySelectedButton.simulate('click');
};
test('available options list width responds to container size', () => {
let popover = mountComponent({ popoverProps: { width: 301 } });
test('available options list width responds to container size', async () => {
let popover = await mountComponent({ popoverProps: { width: 301 } });
let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
expect(availableOptionsDiv.getDOMNode().getAttribute('style')).toBe('width: 301px;');
// the div cannot be smaller than 301 pixels wide
popover = mountComponent({ popoverProps: { width: 300 } });
popover = await mountComponent({ popoverProps: { width: 300 } });
availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
expect(availableOptionsDiv.getDOMNode().getAttribute('style')).toBe(null);
});
test('no available options', () => {
const popover = mountComponent({ componentState: { availableOptions: [] } });
test('no available options', async () => {
const popover = await mountComponent({ componentState: { availableOptions: [] } });
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
const noOptionsDiv = findTestSubject(
availableOptionsDiv,
@ -75,8 +74,8 @@ describe('Options list popover', () => {
expect(noOptionsDiv.exists()).toBeTruthy();
});
test('display error message when the show only selected toggle is true but there are no selections', () => {
const popover = mountComponent();
test('display error message when the show only selected toggle is true but there are no selections', async () => {
const popover = await mountComponent();
clickShowOnlySelections(popover);
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
const noSelectionsDiv = findTestSubject(
@ -86,28 +85,31 @@ describe('Options list popover', () => {
expect(noSelectionsDiv.exists()).toBeTruthy();
});
test('show only selected options', () => {
test('show only selected options', async () => {
const selections = ['woof', 'bark'];
const popover = mountComponent({
const popover = await mountComponent({
explicitInput: { selectedOptions: selections },
});
clickShowOnlySelections(popover);
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
availableOptionsDiv.children().forEach((child, i) => {
expect(child.text()).toBe(selections[i]);
});
availableOptionsDiv
.childAt(0)
.children()
.forEach((child, i) => {
expect(child.text()).toBe(selections[i]);
});
});
test('should default to exclude = false', () => {
const popover = mountComponent();
test('should default to exclude = false', async () => {
const popover = await mountComponent();
const includeButton = findTestSubject(popover, 'optionsList__includeResults');
const excludeButton = findTestSubject(popover, 'optionsList__excludeResults');
expect(includeButton.prop('checked')).toBe(true);
expect(excludeButton.prop('checked')).toBeFalsy();
});
test('if exclude = true, select appropriate button in button group', () => {
const popover = mountComponent({
test('if exclude = true, select appropriate button in button group', async () => {
const popover = await mountComponent({
explicitInput: { exclude: true },
});
const includeButton = findTestSubject(popover, 'optionsList__includeResults');
@ -115,4 +117,56 @@ describe('Options list popover', () => {
expect(includeButton.prop('checked')).toBeFalsy();
expect(excludeButton.prop('checked')).toBe(true);
});
test('clicking another option unselects "Exists"', async () => {
const popover = await mountComponent({
explicitInput: { existsSelected: true },
});
const woofOption = findTestSubject(popover, 'optionsList-control-selection-woof');
woofOption.simulate('click');
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
availableOptionsDiv.children().forEach((child, i) => {
if (child.text() === 'woof') expect(child.prop('checked')).toBe('on');
else expect(child.prop('checked')).toBeFalsy();
});
});
test('clicking "Exists" unselects all other selections', async () => {
const selections = ['woof', 'bark'];
const popover = await mountComponent({
explicitInput: { existsSelected: false, selectedOptions: selections },
});
const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists');
let availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
availableOptionsDiv.children().forEach((child, i) => {
if (selections.includes(child.text())) expect(child.prop('checked')).toBe('on');
else expect(child.prop('checked')).toBeFalsy();
});
existsOption.simulate('click');
availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
availableOptionsDiv.children().forEach((child, i) => {
if (child.text() === 'Exists (*)') expect(child.prop('checked')).toBe('on');
else expect(child.prop('checked')).toBeFalsy();
});
});
test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => {
const popover = await mountComponent({
componentState: { availableOptions: [] },
explicitInput: { existsSelected: false },
});
const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists');
expect(existsOption.exists()).toBeFalsy();
});
test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => {
const popover = await mountComponent({
explicitInput: { existsSelected: true },
});
clickShowOnlySelections(popover);
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
expect(availableOptionsDiv.children().at(0).text()).toBe('Exists');
});
});

View file

@ -6,286 +6,70 @@
* Side Public License, v 1.
*/
import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import { isEmpty } from 'lodash';
import {
EuiFilterSelectItem,
EuiPopoverTitle,
EuiFieldSearch,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiToolTip,
EuiSpacer,
EuiBadge,
EuiIcon,
EuiTitle,
EuiPopoverFooter,
EuiButtonGroup,
useEuiBackgroundColor,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiPopoverTitle } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { optionsListReducers } from '../options_list_reducers';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { OptionsListPopoverFooter } from './options_list_popover_footer';
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions';
import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections';
export interface OptionsListPopoverProps {
width: number;
updateSearchString: (newSearchString: string) => void;
}
const aggregationToggleButtons = [
{
id: 'optionsList__includeResults',
label: OptionsListStrings.popover.getIncludeLabel(),
},
{
id: 'optionsList__excludeResults',
label: OptionsListStrings.popover.getExcludeLabel(),
},
];
export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPopoverProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { selectOption, deselectOption, clearSelections, replaceSelection, setExclude },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const { useEmbeddableSelector: select } = useReduxEmbeddableContext<
OptionsListReduxState,
typeof optionsListReducers
>();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const totalCardinality = select((state) => state.componentState.totalCardinality);
const availableOptions = select((state) => state.componentState.availableOptions);
const searchString = select((state) => state.componentState.searchString);
const field = select((state) => state.componentState.field);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const hideExclude = select((state) => state.explicitInput.hideExclude);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const fieldName = select((state) => state.explicitInput.fieldName);
const title = select((state) => state.explicitInput.title);
const exclude = select((state) => state.explicitInput.exclude);
const loading = select((state) => state.output.loading);
// track selectedOptions and invalidSelections in sets for more efficient lookup
const selectedOptionsSet = useMemo(() => new Set<string>(selectedOptions), [selectedOptions]);
const invalidSelectionsSet = useMemo(
() => new Set<string>(invalidSelections),
[invalidSelections]
);
const id = select((state) => state.explicitInput.id);
const [showOnlySelected, setShowOnlySelected] = useState(false);
const euiBackgroundColor = useEuiBackgroundColor('subdued');
return (
<>
<span
id={`control-popover-${id}`}
role="listbox"
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
>
<EuiPopoverTitle paddingSize="s">{title}</EuiPopoverTitle>
{field?.type !== 'boolean' && (
<div className="optionsList__actions">
<EuiFormRow fullWidth>
<EuiFlexGroup
gutterSize="xs"
direction="row"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem>
<EuiFieldSearch
isInvalid={!searchString.valid}
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => updateSearchString(event.target.value)}
value={searchString.value}
data-test-subj="optionsList-control-search-input"
placeholder={
totalCardinality
? OptionsListStrings.popover.getTotalCardinalityPlaceholder(totalCardinality)
: undefined
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{invalidSelections && invalidSelections.length > 0 && (
<EuiToolTip
content={OptionsListStrings.popover.getInvalidSelectionsTooltip(
invalidSelections.length
)}
>
<EuiBadge className="optionsList__ignoredBadge" color="warning">
{invalidSelections.length}
</EuiBadge>
</EuiToolTip>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
>
<EuiButtonIcon
size="s"
color="danger"
iconType="eraser"
data-test-subj="optionsList-control-clear-all-selections"
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => dispatch(clearSelections({}))}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="s"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
data-test-subj="optionsList-control-show-only-selected"
onClick={() => setShowOnlySelected(!showOnlySelected)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected}
setShowOnlySelected={setShowOnlySelected}
updateSearchString={updateSearchString}
/>
)}
<div
style={{ width: width > 300 ? width : undefined }}
className="optionsList__items"
className="optionsList __items"
data-option-count={availableOptions?.length ?? 0}
data-test-subj={`optionsList-control-available-options`}
>
{!showOnlySelected && (
<>
{availableOptions?.map((availableOption, index) => (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-selection-${availableOption}`}
checked={selectedOptionsSet?.has(availableOption) ? 'on' : undefined}
key={index}
onClick={() => {
if (singleSelect) {
dispatch(replaceSelection(availableOption));
return;
}
if (selectedOptionsSet.has(availableOption)) {
dispatch(deselectOption(availableOption));
return;
}
dispatch(selectOption(availableOption));
}}
>
{`${availableOption}`}
</EuiFilterSelectItem>
))}
{!loading && (!availableOptions || availableOptions.length === 0) && (
<div
className="euiFilterSelect__note"
data-test-subj="optionsList-control-noSelectionsMessage"
>
<div className="euiFilterSelect__noteContent">
<EuiIcon type="minusInCircle" />
<EuiSpacer size="xs" />
<p>{OptionsListStrings.popover.getEmptyMessage()}</p>
</div>
</div>
)}
{!isEmpty(invalidSelections) && (
<>
<EuiSpacer size="s" />
<EuiTitle size="xxs" className="optionsList-control-ignored-selection-title">
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(
invalidSelections?.length ?? 0
)}
</label>
</EuiTitle>
<>
{invalidSelections?.map((ignoredSelection, index) => (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-ignored-selection-${ignoredSelection}`}
checked={'on'}
className="optionsList__selectionInvalid"
key={index}
onClick={() => dispatch(deselectOption(ignoredSelection))}
>
{`${ignoredSelection}`}
</EuiFilterSelectItem>
))}
</>
</>
)}
</>
)}
{showOnlySelected && (
<>
{selectedOptions &&
selectedOptions.map((availableOption, index) => (
<EuiFilterSelectItem
checked={'on'}
key={index}
onClick={() => dispatch(deselectOption(availableOption))}
className={
invalidSelectionsSet.has(availableOption)
? 'optionsList__selectionInvalid'
: undefined
}
>
{`${availableOption}`}
</EuiFilterSelectItem>
))}
{(!selectedOptions || selectedOptions.length === 0) && (
<div
className="euiFilterSelect__note"
data-test-subj="optionsList-control-selectionsEmptyMessage"
>
<div className="euiFilterSelect__noteContent">
<EuiIcon type="minusInCircle" />
<EuiSpacer size="xs" />
<p>{OptionsListStrings.popover.getSelectionsEmptyMessage()}</p>
</div>
</div>
)}
</>
<OptionsListPopoverSuggestions showOnlySelected={showOnlySelected} />
{!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && (
<OptionsListPopoverInvalidSelections />
)}
</div>
{!hideExclude && (
<EuiPopoverFooter
paddingSize="s"
css={css`
background-color: ${euiBackgroundColor};
`}
>
<EuiButtonGroup
legend={OptionsListStrings.popover.getIncludeExcludeLegend()}
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) =>
dispatch(setExclude(optionId === 'optionsList__excludeResults'))
}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"
/>
</EuiPopoverFooter>
)}
</>
{!hideExclude && <OptionsListPopoverFooter />}
</span>
);
};

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
EuiFieldSearch,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiToolTip,
EuiBadge,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
interface OptionsListPopoverProps {
showOnlySelected: boolean;
setShowOnlySelected: (value: boolean) => void;
updateSearchString: (newSearchString: string) => void;
}
export const OptionsListPopoverActionBar = ({
showOnlySelected,
setShowOnlySelected,
updateSearchString,
}: OptionsListPopoverProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { clearSelections },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const totalCardinality = select((state) => state.componentState.totalCardinality);
const searchString = select((state) => state.componentState.searchString);
return (
<div className="optionsList__actions">
<EuiFormRow fullWidth>
<EuiFlexGroup
gutterSize="xs"
direction="row"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem>
<EuiFieldSearch
isInvalid={!searchString.valid}
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => updateSearchString(event.target.value)}
value={searchString.value}
data-test-subj="optionsList-control-search-input"
placeholder={
totalCardinality
? OptionsListStrings.popover.getTotalCardinalityPlaceholder(totalCardinality)
: undefined
}
autoFocus={true}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{(invalidSelections?.length ?? 0) > 0 && (
<EuiToolTip
content={OptionsListStrings.popover.getInvalidSelectionsTooltip(
invalidSelections?.length ?? 0
)}
>
<EuiBadge className="optionsList__ignoredBadge" color="warning">
{invalidSelections?.length}
</EuiBadge>
</EuiToolTip>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
>
<EuiButtonIcon
size="s"
color="danger"
iconType="eraser"
data-test-subj="optionsList-control-clear-all-selections"
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => dispatch(clearSelections({}))}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="s"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
data-test-subj="optionsList-control-show-only-selected"
onClick={() => setShowOnlySelected(!showOnlySelected)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
);
};

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiPopoverFooter, EuiButtonGroup, useEuiBackgroundColor } from '@elastic/eui';
import { css } from '@emotion/react';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
const aggregationToggleButtons = [
{
id: 'optionsList__includeResults',
label: OptionsListStrings.popover.getIncludeLabel(),
},
{
id: 'optionsList__excludeResults',
label: OptionsListStrings.popover.getExcludeLabel(),
},
];
export const OptionsListPopoverFooter = () => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setExclude },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
// Select current state from Redux using multiple selectors to avoid rerenders.
const exclude = select((state) => state.explicitInput.exclude);
return (
<>
<EuiPopoverFooter
paddingSize="s"
css={css`
background-color: ${useEuiBackgroundColor('subdued')};
`}
>
<EuiButtonGroup
legend={OptionsListStrings.popover.getIncludeExcludeLegend()}
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) => dispatch(setExclude(optionId === 'optionsList__excludeResults'))}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"
/>
</EuiPopoverFooter>
</>
);
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiFilterSelectItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
export const OptionsListPopoverInvalidSelections = () => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { deselectOption },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
return (
<>
<EuiSpacer size="s" />
<EuiTitle size="xxs" className="optionsList-control-ignored-selection-title">
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(
invalidSelections?.length ?? 0
)}
</label>
</EuiTitle>
{invalidSelections?.map((ignoredSelection, index) => (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-ignored-selection-${ignoredSelection}`}
checked={'on'}
className="optionsList__selectionInvalid"
key={index}
onClick={() => dispatch(deselectOption(ignoredSelection))}
>
{`${ignoredSelection}`}
</EuiFilterSelectItem>
))}
</>
);
};

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { EuiFilterSelectItem, EuiSpacer, EuiIcon } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
interface OptionsListPopoverSuggestionsProps {
showOnlySelected: boolean;
}
export const OptionsListPopoverSuggestions = ({
showOnlySelected,
}: OptionsListPopoverSuggestionsProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { replaceSelection, deselectOption, selectOption, selectExists },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const availableOptions = select((state) => state.componentState.availableOptions);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const existsSelected = select((state) => state.explicitInput.existsSelected);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const hideExists = select((state) => state.explicitInput.hideExists);
const loading = select((state) => state.output.loading);
// track selectedOptions and invalidSelections in sets for more efficient lookup
const selectedOptionsSet = useMemo(() => new Set<string>(selectedOptions), [selectedOptions]);
const invalidSelectionsSet = useMemo(
() => new Set<string>(invalidSelections),
[invalidSelections]
);
const suggestions = showOnlySelected ? selectedOptions : availableOptions;
if (
!loading &&
(!suggestions || suggestions.length === 0) &&
!(showOnlySelected && existsSelected)
) {
return (
<div
className="euiFilterSelect__note"
data-test-subj={`optionsList-control-${
showOnlySelected ? 'selectionsEmptyMessage' : 'noSelectionsMessage'
}`}
>
<div className="euiFilterSelect__noteContent">
<EuiIcon type="minusInCircle" />
<EuiSpacer size="xs" />
<p>
{showOnlySelected
? OptionsListStrings.popover.getSelectionsEmptyMessage()
: OptionsListStrings.popover.getEmptyMessage()}
</p>
</div>
</div>
);
}
return (
<>
{!hideExists && !(showOnlySelected && !existsSelected) && (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-selection-exists`}
checked={existsSelected ? 'on' : undefined}
key={'exists-option'}
onClick={() => {
dispatch(selectExists(!Boolean(existsSelected)));
}}
className="optionsList__existsFilter"
>
{OptionsListStrings.controlAndPopover.getExists()}
</EuiFilterSelectItem>
)}
{suggestions?.map((suggestion, index) => (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-selection-${suggestion}`}
checked={selectedOptionsSet?.has(suggestion) ? 'on' : undefined}
key={index}
onClick={() => {
if (showOnlySelected) {
dispatch(deselectOption(suggestion));
return;
}
if (singleSelect) {
dispatch(replaceSelection(suggestion));
return;
}
if (selectedOptionsSet.has(suggestion)) {
dispatch(deselectOption(suggestion));
return;
}
dispatch(selectOption(suggestion));
}}
className={
showOnlySelected && invalidSelectionsSet.has(suggestion)
? 'optionsList__selectionInvalid'
: undefined
}
>
{`${suggestion}`}
</EuiFilterSelectItem>
))}
</>
);
};

View file

@ -22,6 +22,10 @@ export const OptionsListStrings = {
i18n.translate('controls.optionsList.control.negate', {
defaultMessage: 'NOT',
}),
getExcludeExists: () =>
i18n.translate('controls.optionsList.control.excludeExists', {
defaultMessage: 'DOES NOT',
}),
},
editor: {
getAllowMultiselectTitle: () =>
@ -41,8 +45,21 @@ export const OptionsListStrings = {
i18n.translate('controls.optionsList.editor.hideExclude', {
defaultMessage: 'Allow selections to be excluded',
}),
getHideExistsQueryTitle: () =>
i18n.translate('controls.optionsList.editor.hideExistsQuery', {
defaultMessage: 'Allow exists query',
}),
getHideExistsQueryTooltip: () =>
i18n.translate('controls.optionsList.editor.hideExistsQueryTooltip', {
defaultMessage: 'Returns the documents that contain an indexed value for the field.',
}),
},
popover: {
getAriaLabel: (fieldName: string) =>
i18n.translate('controls.optionsList.popover.ariaLabel', {
defaultMessage: 'Popover for {fieldName} control',
values: { fieldName },
}),
getLoadingMessage: () =>
i18n.translate('controls.optionsList.popover.loading', {
defaultMessage: 'Loading options',
@ -112,4 +129,11 @@ export const OptionsListStrings = {
defaultMessage: 'Include or exclude selections',
}),
},
controlAndPopover: {
getExists: (negate: number = +false) =>
i18n.translate('controls.optionsList.controlAndPopover.exists', {
defaultMessage: '{negate, plural, one {Exist} other {Exists}}',
values: { negate },
}),
},
};

View file

@ -21,6 +21,7 @@ import {
buildPhraseFilter,
buildPhrasesFilter,
COMPARE_ALL_OPTIONS,
buildExistsFilter,
} from '@kbn/es-query';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { DataView } from '@kbn/data-views-plugin/public';
@ -128,13 +129,14 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
map((newInput) => ({
validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations),
lastReloadRequestTime: newInput.lastReloadRequestTime,
existsSelected: newInput.existsSelected,
dataViewId: newInput.dataViewId,
fieldName: newInput.fieldName,
timeRange: newInput.timeRange,
timeslice: newInput.timeslice,
exclude: newInput.exclude,
filters: newInput.filters,
query: newInput.query,
exclude: newInput.exclude,
})),
distinctUntilChanged(diffDataFetchProps)
);
@ -156,7 +158,10 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.getInput$()
.pipe(
distinctUntilChanged(
(a, b) => isEqual(a.selectedOptions, b.selectedOptions) && a.exclude === b.exclude
(a, b) =>
a.exclude === b.exclude &&
a.existsSelected === b.existsSelected &&
isEqual(a.selectedOptions, b.selectedOptions)
)
)
.subscribe(async ({ selectedOptions: newSelectedOptions }) => {
@ -266,7 +271,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
const {
dispatch,
getState,
actions: { setLoading, updateQueryResults, publishFilters, setSearchString },
actions: { setLoading, publishFilters, setSearchString, updateQueryResults },
} = this.reduxEmbeddableTools;
const previousFieldName = this.field?.name;
@ -279,9 +284,8 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
const {
componentState: { searchString },
explicitInput: { selectedOptions, runPastTimeout },
explicitInput: { selectedOptions, runPastTimeout, existsSelected },
} = getState();
dispatch(setLoading(true));
if (searchString.valid) {
// need to get filters, query, ignoreParentSettings, and timeRange from input for inheritance
@ -318,7 +322,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.abortController.signal
);
if (
!selectedOptions ||
(!selectedOptions && !existsSelected) ||
isEmpty(invalidSelections) ||
ignoreParentSettings?.ignoreValidations
) {
@ -333,8 +337,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
} else {
const valid: string[] = [];
const invalid: string[] = [];
for (const selectedOption of selectedOptions) {
for (const selectedOption of selectedOptions ?? []) {
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
@ -369,20 +372,26 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
private buildFilter = async () => {
const { getState } = this.reduxEmbeddableTools;
const { validSelections } = getState().componentState ?? {};
const { existsSelected } = getState().explicitInput ?? {};
const { exclude } = this.getInput();
if (!validSelections || isEmpty(validSelections)) {
if ((!validSelections || isEmpty(validSelections)) && !existsSelected) {
return [];
}
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
let newFilter: Filter;
if (validSelections.length === 1) {
newFilter = buildPhraseFilter(field, validSelections[0], dataView);
} else {
newFilter = buildPhrasesFilter(field, validSelections, dataView);
let newFilter: Filter | undefined;
if (existsSelected) {
newFilter = buildExistsFilter(field, dataView);
} else if (validSelections) {
if (validSelections.length === 1) {
newFilter = buildPhraseFilter(field, validSelections[0], dataView);
} else {
newFilter = buildPhrasesFilter(field, validSelections, dataView);
}
}
if (!newFilter) return [];
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;

View file

@ -51,8 +51,17 @@ export const optionsListReducers = {
state.componentState.searchString.valid = getIpRangeQuery(action.payload).validSearch;
}
},
selectExists: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
if (action.payload) {
state.explicitInput.existsSelected = true;
state.explicitInput.selectedOptions = [];
} else {
state.explicitInput.existsSelected = false;
}
},
selectOption: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<string>) => {
if (!state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false;
state.explicitInput.selectedOptions?.push(action.payload);
},
replaceSelection: (
@ -62,6 +71,7 @@ export const optionsListReducers = {
state.explicitInput.selectedOptions = [action.payload];
},
clearSelections: (state: WritableDraft<OptionsListReduxState>) => {
if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false;
if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
},
setExclude: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {

View file

@ -82,6 +82,7 @@ class OptionsListService implements ControlsOptionsListService {
const timeFilter = timeRange ? timeService.createFilter(dataView, timeRange) : undefined;
const filtersToUse = [...(filters ?? []), ...(timeFilter ? [timeFilter] : [])];
const esFilters = [buildEsQuery(dataView, query ?? [], filtersToUse ?? [])];
return {
...passThroughProps,
filters: esFilters,

View file

@ -27,11 +27,13 @@ interface EsBucket {
*/
export const getValidationAggregationBuilder: () => OptionsListAggregationBuilder = () => ({
buildAggregation: ({ selectedOptions, fieldName }: OptionsListRequestBody) => {
const selectedOptionsFilters = selectedOptions?.reduce((acc, currentOption) => {
acc[currentOption] = { match: { [fieldName]: currentOption } };
return acc;
}, {} as { [key: string]: { match: { [key: string]: string } } });
let selectedOptionsFilters;
if (selectedOptions) {
selectedOptionsFilters = selectedOptions.reduce((acc, currentOption) => {
acc[currentOption] = { match: { [fieldName]: currentOption } };
return acc;
}, {} as { [key: string]: { match: { [key: string]: string } } });
}
return selectedOptionsFilters && !isEmpty(selectedOptionsFilters)
? {
filters: {
@ -44,6 +46,7 @@ export const getValidationAggregationBuilder: () => OptionsListAggregationBuilde
const rawInvalidSuggestions = get(rawEsResult, 'aggregations.validation.buckets') as {
[key: string]: { doc_count: number };
};
return rawInvalidSuggestions && !isEmpty(rawInvalidSuggestions)
? Object.entries(rawInvalidSuggestions)
?.filter(([, value]) => value?.doc_count === 0)

View file

@ -107,7 +107,6 @@ export const setupOptionsListSuggestionsRoute = (
validation: builtValidationAggregation,
}
: {};
const body: SearchRequest['body'] = {
size: 0,
...timeoutSettings,
@ -138,7 +137,6 @@ export const setupOptionsListSuggestionsRoute = (
const totalCardinality = get(rawEsResult, 'aggregations.unique_terms.value');
const suggestions = suggestionBuilder.parse(rawEsResult);
const invalidSelections = validationBuilder.parse(rawEsResult);
return {
suggestions,
totalCardinality,

View file

@ -14,26 +14,58 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const security = getService('security');
const { dashboardControls, common, dashboard, timePicker } = getPageObjects([
const { common, console, dashboard, dashboardControls, header, timePicker } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'console',
'common',
'header',
]);
describe('Dashboard control group hierarchical chaining', () => {
const newDocuments: Array<{ index: string; id: string }> = [];
let controlIds: string[];
const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => {
const ensureAvailableOptionsEql = async (
controlId: string,
expectation: string[],
filterOutExists: boolean = true
) => {
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation);
expect(
await dashboardControls.optionsListPopoverGetAvailableOptions(filterOutExists)
).to.eql(expectation);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
};
const addDocument = async (index: string, document: string) => {
await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
const response = JSON.parse(await console.getResponse());
newDocuments.push({ index, id: response._id });
};
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']);
/* start by adding some incomplete data so that we can test `exists` query */
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
await addDocument(
'animals-cats-2018-01-01',
'"@timestamp": "2018-01-01T16:00:00.000Z", \n"animal": "cat"'
);
await addDocument(
'animals-dogs-2018-01-01',
'"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Max", \n"sound": "woof"'
);
/* then, create our testing dashboard */
await common.navigateToApp('dashboard');
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
@ -65,6 +97,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
after(async () => {
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
for (const { index, id } of newDocuments) {
await console.enterRequest(`\nDELETE /${index}/_doc/${id}`);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
}
await security.testUser.restoreDefaults();
});
@ -128,7 +168,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']);
await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester', 'Max']);
await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']);
});
@ -138,9 +178,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboardControls.optionsListOpenPopover(controlIds[1]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
});
it('Creating "does not exist" query from first control filters the second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('exists');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboard.waitForRenderComplete();
await dashboardControls.optionsListOpenPopover(controlIds[1]);
await dashboardControls.optionsListPopoverClearSelections();
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(['Max']);
await dashboardControls.optionsListOpenPopover(controlIds[2]);
await dashboardControls.optionsListPopoverClearSelections();
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(['woof']);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
});
it('Creating "exists" query from first control filters the second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSetIncludeSelections(true);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboard.waitForRenderComplete();
await dashboardControls.optionsListOpenPopover(controlIds[1]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.not.contain('Max');
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.not.contain(
'woof'
);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
});
@ -151,7 +224,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Selecting an option in the first Options List will not filter the second or third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSetIncludeSelections(true);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
@ -161,6 +233,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'sylvester',
'Fee Fee',
'Rover',
'Max',
]);
await ensureAvailableOptionsEql(controlIds[2], [
'hiss',
@ -171,6 +244,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'growl',
'grr',
'bow ow ow',
'woof',
]);
});
});

View file

@ -21,10 +21,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardPanelActions = getService('dashboardPanelActions');
const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([
const { dashboardControls, timePicker, console, common, dashboard, header } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'console',
'common',
'header',
]);
@ -32,8 +34,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const DASHBOARD_NAME = 'Test Options List Control';
describe('Dashboard options list integration', () => {
const newDocuments: Array<{ index: string; id: string }> = [];
const addDocument = async (index: string, document: string) => {
await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
const response = JSON.parse(await console.getResponse());
newDocuments.push({ index, id: response._id });
};
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']);
/* start by adding some incomplete data so that we can test `exists` query */
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
await addDocument(
'animals-cats-2018-01-01',
'"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"'
);
/* then, create our testing dashboard */
await common.navigateToApp('dashboard');
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
@ -215,7 +238,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Applies query settings to controls', async () => {
it('Applies dashboard query to options list control', async () => {
await queryBar.setQuery('isDog : true ');
await queryBar.setQuery('animal.keyword : "dog" ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
@ -336,10 +359,75 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId);
expect(selectionString).to.be('hiss, grr');
});
it('excluding selections has expected results', async () => {
await dashboard.clickQuickSave();
await dashboard.waitForRenderComplete();
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverClearSelections();
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboard.clearUnsavedChanges();
});
it('including selections has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(true);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(2);
await dashboard.clearUnsavedChanges();
});
describe('test exists query', async () => {
before(async () => {
await dashboardControls.deleteAllControls();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
controlId = (await dashboardControls.getAllControlIds())[0];
});
it('creating exists query has expected results', async () => {
expect((await pieChart.getPieChartValues())[0]).to.be(6);
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('exists');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
expect((await pieChart.getPieChartValues())[0]).to.be(5);
});
it('negating exists query has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(1);
expect((await pieChart.getPieChartValues())[0]).to.be(1);
});
});
after(async () => {
await dashboardControls.deleteAllControls();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',
title: 'Animal Sounds',
});
controlId = (await dashboardControls.getAllControlIds())[0];
});
});
@ -359,7 +447,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Can mark selections invalid with Query', async () => {
await queryBar.setQuery('isDog : false ');
await queryBar.setQuery('NOT animal.keyword : "dog" ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
@ -385,27 +473,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await pieChart.getPieSliceCount()).to.be(2);
});
it('excluding selections has expected results', async () => {
await dashboard.clickQuickSave();
await dashboard.waitForRenderComplete();
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboard.clearUnsavedChanges();
});
it('including selections has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(true);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(2);
await dashboard.clearUnsavedChanges();
});
it('Can mark multiple selections invalid with Filter', async () => {
await filterBar.addFilter('sound.keyword', 'is', ['hiss']);
await dashboard.waitForRenderComplete();
@ -429,7 +496,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Does not mark selections invalid with Query', async () => {
await queryBar.setQuery('isDog : false ');
await queryBar.setQuery('NOT animal.keyword : "dog" ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
@ -448,8 +515,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await filterBar.removeAllFilters();
await queryBar.clickQuerySubmitButton();
await dashboardControls.clearAllControls();
await security.testUser.restoreDefaults();
});
});
after(async () => {
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
for (const { index, id } of newDocuments) {
await console.enterRequest(`\nDELETE /${index}/_doc/${id}`);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
}
await security.testUser.restoreDefaults();
});
});
}

View file

@ -346,10 +346,11 @@ export class DashboardPageControls extends FtrService {
return +(await availableOptions.getAttribute('data-option-count'));
}
public async optionsListPopoverGetAvailableOptions() {
this.log.debug(`getting available options count from options list`);
public async optionsListPopoverGetAvailableOptions(filterOutExists: boolean = true) {
this.log.debug(`getting available options from options list`);
const availableOptions = await this.testSubjects.find(`optionsList-control-available-options`);
return (await availableOptions.getVisibleText()).split('\n');
const availableOptionsArray = (await availableOptions.getVisibleText()).split('\n');
return filterOutExists ? availableOptionsArray.slice(1) : availableOptionsArray;
}
public async optionsListPopoverSearchForOption(search: string) {

View file

@ -178,6 +178,22 @@ export class PieChartService extends FtrService {
);
}
async getPieChartValues(isNewLibrary: boolean = true) {
this.log.debug('PieChart.getPieChartValues');
if (isNewLibrary) {
const slices =
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
return slices.map((slice) => {
return slice.value;
});
}
const chartTypes = await this.find.allByCssSelector('path.slice', this.defaultFindTimeout * 2);
return await Promise.all(
chartTypes.map(async (chart) => await chart.getAttribute('data-value'))
);
}
async getPieSliceCount(isNewLibrary: boolean = true) {
this.log.debug('PieChart.getPieSliceCount');
if (isNewLibrary) {