Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7a260b3
Mock first attempt at UI
Heenawter Oct 19, 2022
98c7112
Add `exists` filter functionality
Heenawter Oct 20, 2022
efa5462
Make `exists` selection change button
Heenawter Oct 20, 2022
f5dcc03
Overwrite selections instead of disabling them
Heenawter Oct 20, 2022
405434d
Add support for negate to exists
Heenawter Oct 25, 2022
70a0e6f
Add to diffing system
Heenawter Oct 25, 2022
721670d
Add toggle to disable `exists` query
Heenawter Oct 25, 2022
e11b760
Clear `exists` selection when toggle is disabled + fix mocks
Heenawter Oct 25, 2022
92dab10
Switch to tooltip instead of docs link
Heenawter Oct 25, 2022
865b066
Clean up popover logic
Heenawter Oct 25, 2022
a313b45
Fix rendering through memoization
Heenawter Oct 25, 2022
166c369
Auto focus on search when popover opens
Heenawter Oct 26, 2022
ed77465
Added Jest unit tests
Heenawter Oct 26, 2022
4eefecb
Beef up mock and add more Jest unit tests
Heenawter Oct 26, 2022
72d595c
Add functional tests
Heenawter Oct 27, 2022
4b875e3
Split up popover in to smaller components
Heenawter Oct 27, 2022
afc0bcf
Fix unit tests + functional test flakiness
Heenawter Oct 28, 2022
52961b4
Fix flakiness a second time + add chaining tests
Heenawter Oct 28, 2022
899a7f9
Clean up code
Heenawter Oct 31, 2022
52ad6cf
Add `exists` selection to validation
Heenawter Nov 2, 2022
dcd5ca8
Fix invalid bug
Heenawter Nov 2, 2022
a45b451
Fix failing unit test
Heenawter Nov 2, 2022
30c2a2a
More code clean up
Heenawter Nov 2, 2022
fd89c1e
Add another functional test
Heenawter Nov 2, 2022
8af6c57
Apply styling changes
Heenawter Nov 3, 2022
5927369
Fix tests
Heenawter Nov 3, 2022
b1bbfc8
Fix a11y issues
Heenawter Nov 3, 2022
20a1dd2
Remove validation
Heenawter Nov 3, 2022
1f3bd1b
Fix types
Heenawter Nov 3, 2022
0318b0a
Clean up `a11y` fix
Heenawter Nov 3, 2022
64c11c9
Fix jest test
Heenawter Nov 3, 2022
7702df3
Address feedback
Heenawter Nov 4, 2022
a87f33d
Fix wording of tooltip
Heenawter Nov 4, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 21 additions & 20 deletions src/plugins/controls/common/options_list/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
};
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 @@ -15,9 +15,11 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl';

export interface OptionsListEmbeddableInput extends DataControlInput {
selectedOptions?: string[];
existsSelected?: boolean;
Copy link
Contributor Author

@Heenawter Heenawter Oct 31, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that, rather than storing existsSelected as a separate property, I could have included this status in the selectedOptions array. However, after further investigation, this added extra complexity to the code for, in my opinion, minimal benefit.

While this change would clean up the OptionsListEmbeddableInput a bit, treating the exists query the same as any other selection means that every selection/deselection action, including the validation, has two cases: one, exists is selected/deselected or two, something other than exists is selected/deselected. This resulted in many selectedOptions.contains(...) checks in various selection related places.

Therefore, because the logic for handling an exists selection is so different than any other selection, I chose to just keep it separate to avoid the expensive contains checks.

runPastTimeout?: boolean;
singleSelect?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
exclude?: boolean;
}

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

Expand Down Expand Up @@ -83,22 +78,34 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
selectionDisplayNode: (
<>
{exclude && (
<EuiTextColor color="danger">
<b>{OptionsListStrings.control.getNegate()}</b>{' '}
</EuiTextColor>
)}
{validSelections && (
<span>{validSelections?.join(OptionsListStrings.control.getSeparator())}</span>
<>
<span className="optionsList__negateLabel">
{existsSelected
? OptionsListStrings.control.getExcludeExists()
: OptionsListStrings.control.getNegate()}
</span>{' '}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to add this space using CSS :after, but then this messes with the screen reader because the title of the button becomes "DOES NOTExist"- so, need to add the space explicitly here instead.

</>
)}
{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}>
Expand All @@ -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>
);
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -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>
Expand All @@ -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>
</>
);
Expand Down
Loading