[Dashboard] [Controls] Add excludes toggle to options list (#142780)

* Add buttons with no functionality

* Added basic negate functionality

* Add `NOT` text when negated

* Clean up

* Add jest and functional tests

* Fix merge conflicts

* Rename `negate` to `exclude`

* Fix `unsaved changes` bug

* Move erase button back to beside search

* Clean up

* Add chaining functional tests

* Fix other unsaved changes bug

* Fix mobile view of popover

* Add option to disable exclude/include toggle

* Prevent unsaved changes bug for options list settings

* Add tooltip to run past timeout setting

* Address review comments

* Rename variable

* Set `exclude` to `false` when footer is hidden
This commit is contained in:
Hannah Mudge 2022-10-25 10:50:20 -06:00 committed by GitHub
parent a0d237b62c
commit 7dd7a74820
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 299 additions and 12 deletions

View file

@ -0,0 +1,61 @@
/*
* 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 deepEqual from 'fast-deep-equal';
import { omit, isEqual } from 'lodash';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../options_list/types';
import { ControlPanelState } from './types';
interface DiffSystem {
getPanelIsEqual: (initialInput: ControlPanelState, newInput: ControlPanelState) => boolean;
}
export const genericControlPanelDiffSystem: DiffSystem = {
getPanelIsEqual: (initialInput, newInput) => {
return deepEqual(initialInput, newInput);
},
};
export const ControlPanelDiffSystems: {
[key: string]: DiffSystem;
} = {
[OPTIONS_LIST_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
const {
exclude: excludeA,
selectedOptions: selectedA,
singleSelect: singleSelectA,
hideExclude: hideExcludeA,
runPastTimeout: runPastTimeoutA,
...inputA
}: Partial<OptionsListEmbeddableInput> = initialInput.explicitInput;
const {
exclude: excludeB,
selectedOptions: selectedB,
singleSelect: singleSelectB,
hideExclude: hideExcludeB,
runPastTimeout: runPastTimeoutB,
...inputB
}: Partial<OptionsListEmbeddableInput> = newInput.explicitInput;
return (
Boolean(excludeA) === Boolean(excludeB) &&
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(hideExcludeA) === Boolean(hideExcludeB) &&
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
isEqual(selectedA ?? [], selectedB ?? []) &&
deepEqual(inputA, inputB)
);
},
},
};

View file

@ -9,7 +9,7 @@
import { SerializableRecord } from '@kbn/utility-types';
import deepEqual from 'fast-deep-equal';
import { pick } from 'lodash';
import { pick, omit, xor } from 'lodash';
import { ControlGroupInput } from '..';
import {
DEFAULT_CONTROL_GROW,
@ -17,6 +17,10 @@ import {
DEFAULT_CONTROL_WIDTH,
} from './control_group_constants';
import { PersistableControlGroupInput, RawControlGroupAttributes } from './types';
import {
ControlPanelDiffSystems,
genericControlPanelDiffSystem,
} from './control_group_panel_diff_system';
const safeJSONParse = <OutType>(jsonString?: string): OutType | undefined => {
if (!jsonString && typeof jsonString !== 'string') return;
@ -54,10 +58,40 @@ export const persistableControlGroupInputIsEqual = (
...defaultInput,
...pick(b, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']),
};
if (deepEqual(inputA, inputB)) return true;
if (
getPanelsAreEqual(inputA.panels, inputB.panels) &&
deepEqual(omit(inputA, 'panels'), omit(inputB, 'panels'))
)
return true;
return false;
};
const getPanelsAreEqual = (
originalPanels: PersistableControlGroupInput['panels'],
newPanels: PersistableControlGroupInput['panels']
) => {
const originalPanelIds = Object.keys(originalPanels);
const newPanelIds = Object.keys(newPanels);
const panelIdDiff = xor(originalPanelIds, newPanelIds);
if (panelIdDiff.length > 0) {
return false;
}
for (const panelId of newPanelIds) {
const newPanelType = newPanels[panelId].type;
const panelIsEqual = ControlPanelDiffSystems[newPanelType]
? ControlPanelDiffSystems[newPanelType].getPanelIsEqual(
originalPanels[panelId],
newPanels[panelId]
)
: genericControlPanelDiffSystem.getPanelIsEqual(originalPanels[panelId], newPanels[panelId]);
if (!panelIsEqual) return false;
}
return true;
};
export const controlGroupInputToRawControlGroupAttributes = (
controlGroupInput: Omit<ControlGroupInput, 'id'>
): RawControlGroupAttributes => {

View file

@ -28,6 +28,8 @@ const mockOptionsListEmbeddableInput = {
selectedOptions: [],
runPastTimeout: false,
singleSelect: false,
allowExclude: false,
exclude: false,
} as OptionsListEmbeddableInput;
const mockOptionsListOutput = {

View file

@ -17,6 +17,8 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
selectedOptions?: string[];
runPastTimeout?: boolean;
singleSelect?: boolean;
hideExclude?: boolean;
exclude?: boolean;
}
export type OptionsListField = FieldSpec & {

View file

@ -11,7 +11,13 @@ import classNames from 'classnames';
import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui';
import {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiTextColor,
useResizeObserver,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListStrings } from './options_list_strings';
@ -43,6 +49,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
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 loading = select((state) => state.output.loading);
@ -75,6 +82,11 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
validSelectionsCount: validSelections?.length,
selectionDisplayNode: (
<>
{exclude && (
<EuiTextColor color="danger">
<b>{OptionsListStrings.control.getNegate()}</b>{' '}
</EuiTextColor>
)}
{validSelections && (
<span>{validSelections?.join(OptionsListStrings.control.getSeparator())}</span>
)}
@ -86,7 +98,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
</>
),
};
}, [validSelections, invalidSelections]);
}, [exclude, validSelections, invalidSelections]);
const button = (
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>

View file

@ -8,7 +8,8 @@
import React, { useState } from 'react';
import { EuiFormRow, EuiSwitch } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, EuiSwitch } from '@elastic/eui';
import { css } from '@emotion/react';
import { OptionsListStrings } from './options_list_strings';
import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
@ -16,6 +17,7 @@ import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
interface OptionsListEditorState {
singleSelect?: boolean;
runPastTimeout?: boolean;
hideExclude?: boolean;
}
export const OptionsListEditorOptions = ({
@ -25,6 +27,7 @@ export const OptionsListEditorOptions = ({
const [state, setState] = useState<OptionsListEditorState>({
singleSelect: initialInput?.singleSelect,
runPastTimeout: initialInput?.runPastTimeout,
hideExclude: initialInput?.hideExclude,
});
return (
@ -41,14 +44,40 @@ export const OptionsListEditorOptions = ({
</EuiFormRow>
<EuiFormRow>
<EuiSwitch
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
checked={Boolean(state.runPastTimeout)}
label={OptionsListStrings.editor.getHideExcludeTitle()}
checked={!state.hideExclude}
onChange={() => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
onChange({ hideExclude: !state.hideExclude });
setState((s) => ({ ...s, hideExclude: !s.hideExclude }));
if (initialInput?.exclude) onChange({ exclude: false });
}}
/>
</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>
</EuiFormRow>
</>
);
};

View file

@ -97,4 +97,22 @@ describe('Options list popover', () => {
expect(child.text()).toBe(selections[i]);
});
});
test('should default to exclude = false', () => {
const popover = 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({
explicitInput: { exclude: true },
});
const includeButton = findTestSubject(popover, 'optionsList__includeResults');
const excludeButton = findTestSubject(popover, 'optionsList__excludeResults');
expect(includeButton.prop('checked')).toBeFalsy();
expect(excludeButton.prop('checked')).toBe(true);
});
});

View file

@ -22,7 +22,11 @@ import {
EuiBadge,
EuiIcon,
EuiTitle,
EuiPopoverFooter,
EuiButtonGroup,
useEuiBackgroundColor,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { optionsListReducers } from '../options_list_reducers';
@ -34,12 +38,23 @@ export interface OptionsListPopoverProps {
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 },
actions: { selectOption, deselectOption, clearSelections, replaceSelection, setExclude },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
@ -52,8 +67,10 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
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 title = select((state) => state.explicitInput.title);
const exclude = select((state) => state.explicitInput.exclude);
const loading = select((state) => state.output.loading);
@ -65,6 +82,7 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
);
const [showOnlySelected, setShowOnlySelected] = useState(false);
const euiBackgroundColor = useEuiBackgroundColor('subdued');
return (
<>
@ -77,6 +95,7 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
direction="row"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem>
<EuiFieldSearch
@ -248,6 +267,25 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
</>
)}
</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>
)}
</>
);
};

View file

@ -18,6 +18,10 @@ export const OptionsListStrings = {
i18n.translate('controls.optionsList.control.placeholder', {
defaultMessage: 'Any',
}),
getNegate: () =>
i18n.translate('controls.optionsList.control.negate', {
defaultMessage: 'NOT',
}),
},
editor: {
getAllowMultiselectTitle: () =>
@ -26,7 +30,16 @@ export const OptionsListStrings = {
}),
getRunPastTimeoutTitle: () =>
i18n.translate('controls.optionsList.editor.runPastTimeout', {
defaultMessage: 'Run past timeout',
defaultMessage: 'Ignore timeout for results',
}),
getRunPastTimeoutTooltip: () =>
i18n.translate('controls.optionsList.editor.runPastTimeout.tooltip', {
defaultMessage:
'Wait to display results until the list is complete. This setting is useful for large data sets, but the results might take longer to populate.',
}),
getHideExcludeTitle: () =>
i18n.translate('controls.optionsList.editor.hideExclude', {
defaultMessage: 'Allow selections to be excluded',
}),
},
popover: {
@ -86,5 +99,17 @@ export const OptionsListStrings = {
'{selectedOptions} selected {selectedOptions, plural, one {option} other {options}} {selectedOptions, plural, one {is} other {are}} ignored because {selectedOptions, plural, one {it is} other {they are}} no longer in the data.',
values: { selectedOptions },
}),
getIncludeLabel: () =>
i18n.translate('controls.optionsList.popover.includeLabel', {
defaultMessage: 'Include',
}),
getExcludeLabel: () =>
i18n.translate('controls.optionsList.popover.excludeLabel', {
defaultMessage: 'Exclude',
}),
getIncludeExcludeLegend: () =>
i18n.translate('controls.optionsList.popover.excludeOptionsLegend', {
defaultMessage: 'Include or exclude selections',
}),
},
};

View file

@ -134,6 +134,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
timeslice: newInput.timeslice,
filters: newInput.filters,
query: newInput.query,
exclude: newInput.exclude,
})),
distinctUntilChanged(diffDataFetchProps)
);
@ -153,7 +154,11 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
**/
this.subscriptions.add(
this.getInput$()
.pipe(distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)))
.pipe(
distinctUntilChanged(
(a, b) => isEqual(a.selectedOptions, b.selectedOptions) && a.exclude === b.exclude
)
)
.subscribe(async ({ selectedOptions: newSelectedOptions }) => {
const {
actions: {
@ -364,6 +369,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
private buildFilter = async () => {
const { getState } = this.reduxEmbeddableTools;
const { validSelections } = getState().componentState ?? {};
const { exclude } = this.getInput();
if (!validSelections || isEmpty(validSelections)) {
return [];
@ -379,6 +385,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
}
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;
return [newFilter];
};

View file

@ -64,6 +64,9 @@ export const optionsListReducers = {
clearSelections: (state: WritableDraft<OptionsListReduxState>) => {
if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
},
setExclude: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.explicitInput.exclude = action.payload;
},
clearValidAndInvalidSelections: (state: WritableDraft<OptionsListReduxState>) => {
state.componentState.invalidSelections = [];
state.componentState.validSelections = [];

View file

@ -123,6 +123,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
});
it('Excluding selections in the first control will validate the second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']);
await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']);
});
it('Excluding all options of first control removes all options in second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboardControls.optionsListOpenPopover(controlIds[1]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0);
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
});
describe('Hierarchical chaining off', async () => {
before(async () => {
await dashboardControls.updateChainingSystem('NONE');
@ -130,6 +151,7 @@ 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]);

View file

@ -385,6 +385,27 @@ 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();

View file

@ -376,6 +376,19 @@ export class DashboardPageControls extends FtrService {
await this.testSubjects.click(`optionsList-control-clear-all-selections`);
}
public async optionsListPopoverSetIncludeSelections(include: boolean) {
this.log.debug(`exclude selections`);
await this.optionsListPopoverAssertOpen();
const buttonGroup = await this.testSubjects.find('optionsList__includeExcludeButtonGroup');
await (
await this.find.descendantDisplayedByCssSelector(
include ? '[data-text="Include"]' : '[data-text="Exclude"]',
buttonGroup
)
).click();
}
/* -----------------------------------------------------------
Control editor flyout
----------------------------------------------------------- */