Skip to content

Commit

Permalink
[Dashboard] [Controls] Add excludes toggle to options list (#142780)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Heenawter authored Oct 25, 2022
1 parent a0d237b commit 7dd7a74
Show file tree
Hide file tree
Showing 14 changed files with 299 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -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)
);
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@
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,
DEFAULT_CONTROL_STYLE,
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;
Expand Down Expand Up @@ -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 => {
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/controls/common/options_list/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const mockOptionsListEmbeddableInput = {
selectedOptions: [],
runPastTimeout: false,
singleSelect: false,
allowExclude: false,
exclude: false,
} as OptionsListEmbeddableInput;

const mockOptionsListOutput = {
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/controls/common/options_list/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
selectedOptions?: string[];
runPastTimeout?: boolean;
singleSelect?: boolean;
hideExclude?: boolean;
exclude?: boolean;
}

export type OptionsListField = FieldSpec & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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>
)}
Expand All @@ -86,7 +98,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
</>
),
};
}, [validSelections, invalidSelections]);
}, [exclude, validSelections, invalidSelections]);

const button = (
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@

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 '../..';

interface OptionsListEditorState {
singleSelect?: boolean;
runPastTimeout?: boolean;
hideExclude?: boolean;
}

export const OptionsListEditorOptions = ({
Expand All @@ -25,6 +27,7 @@ export const OptionsListEditorOptions = ({
const [state, setState] = useState<OptionsListEditorState>({
singleSelect: initialInput?.singleSelect,
runPastTimeout: initialInput?.runPastTimeout,
hideExclude: initialInput?.hideExclude,
});

return (
Expand All @@ -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>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -65,6 +82,7 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
);

const [showOnlySelected, setShowOnlySelected] = useState(false);
const euiBackgroundColor = useEuiBackgroundColor('subdued');

return (
<>
Expand All @@ -77,6 +95,7 @@ export const OptionsListPopover = ({ width, updateSearchString }: OptionsListPop
direction="row"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem>
<EuiFieldSearch
Expand Down Expand Up @@ -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>
)}
</>
);
};
Loading

0 comments on commit 7dd7a74

Please sign in to comment.