mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
88cfa2a4dd
commit
a30e6c2555
23 changed files with 938 additions and 392 deletions
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue