[Security Solution] Fixes the Reset group by fields action when an EuiComboBox has validation errors (#138346)

## [Security Solution] Fixes the `Reset group by fields` action when an `EuiComboBox` has validation errors

### Summary

This PR fixes an issue where the `Reset group by fields` action fails when an `EuiComboBox` has validation errors, per the repro steps in <https://github.com/elastic/kibana/issues/136499>.

### Details

The Alerts page `Treemap`, `Table`, and `Trend` visualizations have `Group by` and `Group by top` `EuiComboBox`s that allow users to select a field name from the combo box, or manually type a value in the input.

As reported in <https://github.com/elastic/kibana/issues/136499>, the `Reset group by fields` action was working as-expected when users selected valid options in the `Group by` and `Group by top` `EuiComboBox`s, but the `Reset group by fields` action failed when users manually typed an invalid value in the inputs.

This failure is demonstrated in the BEFORE / Treemap video below:

#### BEFORE / Treemap

<https://user-images.githubusercontent.com/4459398/183539413-2615c2de-5ddf-45f3-ae8e-895f6ffe4951.mov>

After the fix, the `Reset group by fields` action resets the _Group by_ and _Group by top_ `EuiComboBox`s when they have validation errors, per the (three) AFTER videos below:

#### AFTER / Treemap

<https://user-images.githubusercontent.com/4459398/183539620-571bfb8e-5c75-45d4-91c8-6fff447ba219.mov>

#### AFTER / Table

<https://user-images.githubusercontent.com/4459398/183539664-4a5336c6-44d0-41a4-bcea-d3843bccb191.mov>

#### AFTER / Trend

<https://user-images.githubusercontent.com/4459398/183539698-a2ae8294-44bc-4069-ad2b-bbc8e96694f1.mov>

#### Implementation details

As confirmed with the EUI team, when `EuiComboBox` has validation errors, those errors must be cleared by executing APIs, (i.e. `clearSearchValue`), that are only exposed via `EuiComboBox`'s (optional) `ref`.
This commit is contained in:
Andrew Goldstein 2022-08-09 11:38:38 -06:00 committed by GitHub
parent 287e3b8bad
commit 792ddae2d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 392 additions and 50 deletions

View file

@ -6,6 +6,7 @@
*/
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { EuiComboBox } from '@elastic/eui';
import { EuiProgress } from '@elastic/eui';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
@ -42,10 +43,14 @@ export interface Props {
runtimeMappings?: MappingRuntimeFields;
setIsPanelExpanded: (value: boolean) => void;
setStackByField0: (stackBy: string) => void;
setStackByField0ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
setStackByField1: (stackBy: string | undefined) => void;
setStackByField1ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
signalIndexName: string | null;
stackByField0: string;
stackByField0ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByField1: string | undefined;
stackByField1ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByWidth?: number;
title: React.ReactNode;
}
@ -63,10 +68,14 @@ const AlertsTreemapPanelComponent: React.FC<Props> = ({
runtimeMappings,
setIsPanelExpanded,
setStackByField0,
setStackByField0ComboboxInputRef,
setStackByField1,
setStackByField1ComboboxInputRef,
signalIndexName,
stackByField0,
stackByField0ComboboxRef,
stackByField1,
stackByField1ComboboxRef,
stackByWidth,
title,
}: Props) => {
@ -169,9 +178,13 @@ const AlertsTreemapPanelComponent: React.FC<Props> = ({
<FieldSelection
chartOptionsContextMenu={chartOptionsContextMenu}
setStackByField0={setStackByField0}
setStackByField0ComboboxInputRef={setStackByField0ComboboxInputRef}
setStackByField1={setStackByField1}
setStackByField1ComboboxInputRef={setStackByField1ComboboxInputRef}
stackByField0={stackByField0}
stackByField0ComboboxRef={stackByField0ComboboxRef}
stackByField1={stackByField1}
stackByField1ComboboxRef={stackByField1ComboboxRef}
stackByWidth={stackByWidth}
uniqueQueryId={uniqueQueryId}
/>

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { EuiComboBox } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
@ -22,9 +23,13 @@ const ChartOptionsFlexItem = styled(EuiFlexItem)`
export interface Props {
chartOptionsContextMenu?: (queryId: string) => React.ReactNode;
setStackByField0: (stackBy: string) => void;
setStackByField0ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
setStackByField1: (stackBy: string | undefined) => void;
setStackByField1ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
stackByField0: string;
stackByField0ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByField1: string | undefined;
stackByField1ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByWidth?: number;
uniqueQueryId: string;
}
@ -32,9 +37,13 @@ export interface Props {
const FieldSelectionComponent: React.FC<Props> = ({
chartOptionsContextMenu,
setStackByField0,
setStackByField0ComboboxInputRef,
setStackByField1,
setStackByField1ComboboxInputRef,
stackByField0,
stackByField0ComboboxRef,
stackByField1,
stackByField1ComboboxRef,
stackByWidth,
uniqueQueryId,
}: Props) => (
@ -42,19 +51,23 @@ const FieldSelectionComponent: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<StackByComboBox
aria-label={GROUP_BY_LABEL}
ref={stackByField0ComboboxRef}
data-test-subj="groupBy"
onSelect={setStackByField0}
prepend={GROUP_BY_LABEL}
selected={stackByField0}
inputRef={setStackByField0ComboboxInputRef}
width={stackByWidth}
/>
<EuiSpacer size="s" />
<StackByComboBox
aria-label={GROUP_BY_TOP_LABEL}
ref={stackByField1ComboboxRef}
data-test-subj="groupByTop"
onSelect={setStackByField1}
prepend={GROUP_BY_TOP_LABEL}
selected={stackByField1 ?? ''}
inputRef={setStackByField1ComboboxInputRef}
width={stackByWidth}
/>
</EuiFlexItem>

View file

@ -0,0 +1,84 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { fireEvent, render, screen } from '@testing-library/react';
import React, { useCallback, useMemo, useState } from 'react';
import { useEuiComboBoxReset } from '.';
const options = [
{
label: 'foo',
},
{
label: 'bar',
},
{
label: 'baz',
},
];
describe('useEuiComboBoxReset', () => {
test(`it resets the input, even when the EuiComboBox has validation errors, when 'onReset()' is invoked'`, () => {
const invalidValue = 'this is NOT an option, bub';
const EuiComboBoxResetTest = () => {
const [selectedOptions, setSelected] = useState([options[0]]);
const onChange = useCallback(
(selected: Array<EuiComboBoxOptionOption<string | number | string[] | undefined>>) => {
setSelected(selected);
},
[]
);
const singleSelection = useMemo(() => {
return { asPlainText: true };
}, []);
const { comboboxRef, onReset, setComboboxInputRef } = useEuiComboBoxReset();
return (
<>
<EuiComboBox
aria-label="test"
inputRef={setComboboxInputRef} // from useEuiComboBoxReset
isClearable={false}
ref={comboboxRef} // from useEuiComboBoxReset
selectedOptions={selectedOptions}
singleSelection={singleSelection}
sortMatchesBy="startsWith"
onChange={onChange}
options={options}
/>
<button aria-label="Reset" onClick={() => onReset()} type="button">
{'Reset'}
</button>
</>
);
};
render(<EuiComboBoxResetTest />);
const initialValue = screen.getByTestId('comboBoxInput'); // EuiComboBox does NOT render the current selection via it's input; it uses this div
expect(initialValue).toHaveTextContent(options[0].label);
// update the EuiComboBox input to an invalid value:
const searchInput = screen.getByTestId('comboBoxSearchInput'); // the actual <input /> controlled by EuiComboBox
fireEvent.change(searchInput, { target: { value: invalidValue } });
const afterInvalidInput = screen.getByTestId('comboBoxInput');
expect(afterInvalidInput).toHaveTextContent(invalidValue); // the EuiComboBox is now in the "error state"
const resetButton = screen.getByRole('button', { name: 'Reset' });
fireEvent.click(resetButton); // clicking invokes onReset()
const afterReset = screen.getByTestId('comboBoxInput');
expect(afterReset).toHaveTextContent(options[0].label); // back to the default
});
});

View file

@ -0,0 +1,52 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiComboBox } from '@elastic/eui';
import { useCallback, useState, useRef } from 'react';
/**
* This hook is used to imperatively reset an `EuiComboBox`. When users trigger
* validation errors in the combo box, it will not accept new values
* programmatically until the validation errors are resolved by clearing
* the input.
*
* Q: Why does this hook exist?
*
* A: When users click the `Reset group by field` action, the `EuiComboBox` is
* reset to a default value (e.g. `host.name`) by updating `EuiComboBox`'s
* `selectedOptions` prop. However, if the user has triggered an `EuiComboBox`
* validation error by manually entering text, for example:
* `this text is not a valid option`, the `EuiComboBox` will NOT display the
* updated value of the `selectedOptions` prop, because there are (still)
* validation errors.
*
* This hook returns an `onReset` function that clears the `EuiComboBox` input,
* resolving any validation errors.
*
* NOTE: The `comboboxRef` and `setComboboxInputRef` MUST be provided to
* `EuiComboBox`via it's `ref` and `inputRef` props.
*
* Returns:
* - `onReset`: calling `onReset()` clears the `EuiComboBox` input, resolving any validation errors
* - `comboboxRef`: REQUIRED: provide this value to the `ref` prop of an `EuiComboBox`
* - `setComboboxInputRef`: REQUIRED: provide this function to the `inputRef` prop of an `EuiComboBox`
*/
export const useEuiComboBoxReset = () => {
const comboboxRef = useRef<EuiComboBox<string | number | string[] | undefined>>(null);
const [comboboxInputRef, setComboboxInputRef] = useState<HTMLInputElement | null>(null);
const onReset = useCallback(() => {
comboboxRef.current?.clearSearchValue(); // EuiComboBox attaches the clearSearchValue function to the ref
if (comboboxInputRef != null) {
comboboxInputRef.value = '';
}
}, [comboboxInputRef]);
return { comboboxRef, onReset, setComboboxInputRef };
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { EuiComboBox } from '@elastic/eui';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import React, { memo, useMemo, useState, useEffect, useCallback } from 'react';
import uuid from 'uuid';
@ -36,10 +37,14 @@ interface AlertsCountPanelProps {
panelHeight?: number;
query?: Query;
setStackByField0: (stackBy: string) => void;
setStackByField0ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
setStackByField1: (stackBy: string | undefined) => void;
setStackByField1ComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
signalIndexName: string | null;
stackByField0: string;
stackByField0ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByField1: string | undefined;
stackByField1ComboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
stackByWidth?: number;
title?: React.ReactNode;
runtimeMappings?: MappingRuntimeFields;
@ -55,10 +60,14 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
query,
runtimeMappings,
setStackByField0,
setStackByField0ComboboxInputRef,
setStackByField1,
setStackByField1ComboboxInputRef,
signalIndexName,
stackByField0,
stackByField0ComboboxRef,
stackByField1,
stackByField1ComboboxRef,
stackByWidth,
title = i18n.COUNT_TABLE_TITLE,
}) => {
@ -172,9 +181,13 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
<FieldSelection
chartOptionsContextMenu={chartOptionsContextMenu}
setStackByField0={setStackByField0}
setStackByField0ComboboxInputRef={setStackByField0ComboboxInputRef}
setStackByField1={setStackByField1}
setStackByField1ComboboxInputRef={setStackByField1ComboboxInputRef}
stackByField0={stackByField0}
stackByField0ComboboxRef={stackByField0ComboboxRef}
stackByField1={stackByField1}
stackByField1ComboboxRef={stackByField1ComboboxRef}
stackByWidth={stackByWidth}
uniqueQueryId={uniqueQueryId}
/>

View file

@ -7,7 +7,7 @@
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { Position } from '@elastic/charts';
import type { EuiTitleSize } from '@elastic/eui';
import type { EuiComboBox, EuiTitleSize } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } from '@elastic/eui';
import numeral from '@elastic/numeral';
import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';
@ -72,6 +72,7 @@ interface AlertsHistogramPanelProps {
chartHeight?: number;
chartOptionsContextMenu?: (queryId: string) => React.ReactNode;
combinedQueries?: string;
comboboxRef?: React.RefObject<EuiComboBox<string | number | string[] | undefined>>;
defaultStackByOption?: string;
filters?: Filter[];
headerChildren?: React.ReactNode;
@ -84,6 +85,7 @@ interface AlertsHistogramPanelProps {
titleSize?: EuiTitleSize;
query?: Query;
legendPosition?: Position;
setComboboxInputRef?: (inputRef: HTMLInputElement | null) => void;
signalIndexName: string | null;
showCountsInLegend?: boolean;
showGroupByPlaceholder?: boolean;
@ -107,6 +109,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
chartHeight,
chartOptionsContextMenu,
combinedQueries,
comboboxRef,
defaultStackByOption = DEFAULT_STACK_BY_FIELD,
filters,
headerChildren,
@ -117,6 +120,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
panelHeight = PANEL_HEIGHT,
query,
legendPosition = 'right',
setComboboxInputRef,
signalIndexName,
showCountsInLegend = false,
showGroupByPlaceholder = false,
@ -354,10 +358,12 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
{showStackBy && (
<>
<StackByComboBox
ref={comboboxRef}
data-test-subj="stackByComboBox"
selected={selectedStackByOption}
onSelect={onSelect}
prepend={stackByLabel}
inputRef={setComboboxInputRef}
width={stackByWidth}
/>
{showGroupByPlaceholder && (

View file

@ -7,6 +7,7 @@
import { EuiPanel, EuiComboBox } from '@elastic/eui';
import styled from 'styled-components';
import type { LegacyRef } from 'react';
import React, { useCallback, useMemo } from 'react';
import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config';
import { useStackByFields } from './hooks';
@ -54,6 +55,7 @@ interface StackedBySelectProps {
isDisabled?: boolean;
prepend?: string;
selected: string;
inputRef?: (inputRef: HTMLInputElement | null) => void;
onSelect: (selected: string) => void;
width?: number;
}
@ -63,48 +65,58 @@ export const StackByComboBoxWrapper = styled.div<{ width: number }>`
width: ${({ width }) => width}px;
`;
export const StackByComboBox: React.FC<StackedBySelectProps> = ({
'aria-label': ariaLabel = i18n.STACK_BY_ARIA_LABEL,
'data-test-subj': dataTestSubj,
isDisabled = false,
onSelect,
prepend = i18n.STACK_BY_LABEL,
selected,
width = DEFAULT_WIDTH,
}) => {
const onChange = useCallback(
(options) => {
if (options && options.length > 0) {
onSelect(options[0].value);
} else {
onSelect('');
}
},
[onSelect]
);
const selectedOptions = useMemo(() => {
return [{ label: selected, value: selected }];
}, [selected]);
const stackOptions = useStackByFields();
const singleSelection = useMemo(() => {
return { asPlainText: true };
}, []);
return (
<StackByComboBoxWrapper width={width}>
<EuiComboBox
data-test-subj={dataTestSubj}
aria-label={ariaLabel}
isDisabled={isDisabled}
placeholder={i18n.STACK_BY_PLACEHOLDER}
prepend={prepend}
singleSelection={singleSelection}
isClearable={false}
sortMatchesBy="startsWith"
options={stackOptions}
selectedOptions={selectedOptions}
compressed
onChange={onChange}
/>
</StackByComboBoxWrapper>
);
};
export const StackByComboBox = React.forwardRef(
(
{
'aria-label': ariaLabel = i18n.STACK_BY_ARIA_LABEL,
'data-test-subj': dataTestSubj,
isDisabled = false,
onSelect,
prepend = i18n.STACK_BY_LABEL,
selected,
inputRef,
width = DEFAULT_WIDTH,
}: StackedBySelectProps,
ref
) => {
const onChange = useCallback(
(options) => {
if (options && options.length > 0) {
onSelect(options[0].value);
} else {
onSelect('');
}
},
[onSelect]
);
const selectedOptions = useMemo(() => {
return [{ label: selected, value: selected }];
}, [selected]);
const stackOptions = useStackByFields();
const singleSelection = useMemo(() => {
return { asPlainText: true };
}, []);
return (
<StackByComboBoxWrapper width={width}>
<EuiComboBox
data-test-subj={dataTestSubj}
aria-label={ariaLabel}
inputRef={inputRef}
isDisabled={isDisabled}
placeholder={i18n.STACK_BY_PLACEHOLDER}
prepend={prepend}
ref={ref as LegacyRef<EuiComboBox<string | number | string[] | undefined>> | undefined}
singleSelection={singleSelection}
isClearable={false}
sortMatchesBy="startsWith"
options={stackOptions}
selectedOptions={selectedOptions}
compressed
onChange={onChange}
/>
</StackByComboBoxWrapper>
);
}
);
StackByComboBox.displayName = 'StackByComboBox';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { RESET_GROUP_BY_FIELDS } from '../../../../../common/components/chart_settings_popover/configurations/default/translations';
@ -84,4 +84,29 @@ describe('ChartContextMenu', () => {
expect(setStackBy).toBeCalledWith('kibana.alert.rule.name');
expect(setStackByField1).toBeCalledWith('host.name');
});
test('it invokes `onReset` when the `Reset group by fields` menu item clicked', () => {
const onReset = jest.fn();
render(
<TestProviders>
<ChartContextMenu
defaultStackByField={DEFAULT_STACK_BY_FIELD}
defaultStackByField1={DEFAULT_STACK_BY_FIELD1}
queryId={queryId}
onReset={onReset}
setStackBy={jest.fn()}
setStackByField1={jest.fn()}
/>
</TestProviders>
);
const menuButton = screen.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL });
fireEvent.click(menuButton);
const resetMenuItem = screen.getByRole('button', { name: RESET_GROUP_BY_FIELDS });
fireEvent.click(resetMenuItem);
expect(onReset).toBeCalled();
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { noop } from 'lodash/fp';
import React, { useCallback } from 'react';
import { ChartSettingsPopover } from '../../../../../common/components/chart_settings_popover';
@ -16,22 +17,26 @@ interface Props {
queryId: string;
setStackBy: (value: string) => void;
setStackByField1?: (stackBy: string | undefined) => void;
onReset?: () => void;
}
const ChartContextMenuComponent: React.FC<Props> = ({
defaultStackByField,
defaultStackByField1,
onReset = noop,
queryId,
setStackBy,
setStackByField1,
}: Props) => {
const onResetStackByFields = useCallback(() => {
onReset();
setStackBy(defaultStackByField);
if (setStackByField1 != null) {
setStackByField1(defaultStackByField1);
}
}, [defaultStackByField, defaultStackByField1, setStackBy, setStackByField1]);
}, [defaultStackByField, defaultStackByField1, onReset, setStackBy, setStackByField1]);
const { defaultInitialPanelId, defaultMenuItems, isPopoverOpen, setIsPopoverOpen } =
useChartSettingsPopoverConfiguration({

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { useAlertsLocalStorage } from './alerts_local_storage';
import { RESET_GROUP_BY_FIELDS } from '../../../../common/components/chart_settings_popover/configurations/default/translations';
import { CHART_SETTINGS_POPOVER_ARIA_LABEL } from '../../../../common/components/chart_settings_popover/translations';
import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { TestProviders } from '../../../../common/mock';
@ -112,6 +114,14 @@ const defaultProps = {
updateDateRangeCallback: jest.fn(),
};
const resetGroupByFields = () => {
const menuButton = screen.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL });
fireEvent.click(menuButton);
const resetMenuItem = screen.getByRole('button', { name: RESET_GROUP_BY_FIELDS });
fireEvent.click(resetMenuItem);
};
describe('ChartPanels', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -163,6 +173,86 @@ describe('ChartPanels', () => {
});
});
describe(`'Reset group by fields' context menu action`, () => {
describe('Group by', () => {
const alertViewSelections = ['trend', 'table', 'treemap'];
alertViewSelections.forEach((alertViewSelection) => {
test(`it resets the 'Group by' field to the default value, even if the user has triggered validation errors, when 'alertViewSelection' is '${alertViewSelection}'`, async () => {
(useAlertsLocalStorage as jest.Mock).mockReturnValue({
...defaultAlertSettings,
alertViewSelection,
});
const defaultValue = 'kibana.alert.rule.name';
const invalidValue = 'an invalid value';
render(
<TestProviders>
<ChartPanels {...defaultProps} />
</TestProviders>
);
const initialValue = screen.getAllByTestId('comboBoxInput')[0]; // EuiComboBox does NOT render the current selection via it's input; it uses this div
expect(initialValue).toHaveTextContent(defaultValue);
// update the EuiComboBox input to an invalid value:
const searchInput = screen.getAllByTestId('comboBoxSearchInput')[0]; // the actual <input /> controlled by EuiComboBox
fireEvent.change(searchInput, { target: { value: invalidValue } });
const afterInvalidInput = screen.getAllByTestId('comboBoxInput')[0];
expect(afterInvalidInput).toHaveTextContent(invalidValue); // the 'Group by' EuiComboBox is now in the "error state"
resetGroupByFields(); // invoke the `Reset group by fields` context menu action
await waitFor(() => {
const afterReset = screen.getAllByTestId('comboBoxInput')[0];
expect(afterReset).toHaveTextContent(defaultValue); // back to the default
});
});
});
});
describe('Group by top', () => {
const justTableAndTreemap = ['table', 'treemap'];
justTableAndTreemap.forEach((alertViewSelection) => {
test(`it resets the 'Group by top' field to the default value, even if the user has triggered validation errors, when 'alertViewSelection' is '${alertViewSelection}'`, async () => {
(useAlertsLocalStorage as jest.Mock).mockReturnValue({
...defaultAlertSettings,
alertViewSelection,
});
const defaultValue = 'host.name';
const invalidValue = 'an-invalid-value';
render(
<TestProviders>
<ChartPanels {...defaultProps} />
</TestProviders>
);
const initialValue = screen.getAllByTestId('comboBoxInput')[1]; // EuiComboBox does NOT render the current selection via it's input; it uses this div
expect(initialValue).toHaveTextContent(defaultValue);
// update the EuiComboBox input to an invalid value:
const searchInput = screen.getAllByTestId('comboBoxSearchInput')[1]; // the actual <input /> controlled by EuiComboBox
fireEvent.change(searchInput, { target: { value: invalidValue } });
const afterInvalidInput = screen.getAllByTestId('comboBoxInput')[1];
expect(afterInvalidInput).toHaveTextContent(invalidValue); // the 'Group by top' EuiComboBox is now in the "error state"
resetGroupByFields(); // invoke the `Reset group by fields` context menu action
await waitFor(() => {
const afterReset = screen.getAllByTestId('comboBoxInput')[1];
expect(afterReset).toHaveTextContent(defaultValue); // back to the default
});
});
});
});
});
test('it renders the table loading spinner when data is loading and `alertViewSelection` is table', async () => {
(useAlertsLocalStorage as jest.Mock).mockReturnValue({
...defaultAlertSettings,

View file

@ -18,6 +18,7 @@ import { ChartSelect } from './chart_select';
import { TABLE, TREEMAP, TREND } from './chart_select/translations';
import { AlertsTreemapPanel } from '../../../../common/components/alerts_treemap_panel';
import type { UpdateDateRange } from '../../../../common/components/charts/common';
import { useEuiComboBoxReset } from '../../../../common/components/use_combo_box_reset';
import { AlertsHistogramPanel } from '../../../components/alerts_kpis/alerts_histogram_panel';
import {
DEFAULT_STACK_BY_FIELD,
@ -91,17 +92,35 @@ const ChartPanelsComponent: React.FC<Props> = ({
[setCountTableStackBy1, setRiskChartStackBy1]
);
const {
comboboxRef: stackByField0ComboboxRef,
onReset: onResetStackByField0,
setComboboxInputRef: setStackByField0ComboboxInputRef,
} = useEuiComboBoxReset();
const {
comboboxRef: stackByField1ComboboxRef,
onReset: onResetStackByField1,
setComboboxInputRef: setStackByField1ComboboxInputRef,
} = useEuiComboBoxReset();
const onReset = useCallback(() => {
onResetStackByField0();
onResetStackByField1();
}, [onResetStackByField0, onResetStackByField1]);
const chartOptionsContextMenu = useCallback(
(queryId: string) => (
<ChartContextMenu
defaultStackByField={DEFAULT_STACK_BY_FIELD}
defaultStackByField1={DEFAULT_STACK_BY_FIELD1}
onReset={onReset}
queryId={queryId}
setStackBy={updateCommonStackBy0}
setStackByField1={updateCommonStackBy1}
/>
),
[updateCommonStackBy0, updateCommonStackBy1]
[onReset, updateCommonStackBy0, updateCommonStackBy1]
);
const title = useMemo(
@ -127,9 +146,11 @@ const ChartPanelsComponent: React.FC<Props> = ({
alignHeader="flexStart"
chartHeight={TRENT_CHART_HEIGHT}
chartOptionsContextMenu={chartOptionsContextMenu}
comboboxRef={stackByField0ComboboxRef}
defaultStackByOption={trendChartStackBy}
filters={alertsHistogramDefaultFilters}
inspectTitle={TREND}
setComboboxInputRef={setStackByField0ComboboxInputRef}
onFieldSelected={updateCommonStackBy0}
panelHeight={TREND_CHART_PANEL_HEIGHT}
query={query}
@ -161,7 +182,11 @@ const ChartPanelsComponent: React.FC<Props> = ({
query={query}
runtimeMappings={runtimeMappings}
setStackByField0={updateCommonStackBy0}
setStackByField0ComboboxInputRef={setStackByField0ComboboxInputRef}
stackByField0ComboboxRef={stackByField0ComboboxRef}
setStackByField1={updateCommonStackBy1}
setStackByField1ComboboxInputRef={setStackByField1ComboboxInputRef}
stackByField1ComboboxRef={stackByField1ComboboxRef}
signalIndexName={signalIndexName}
stackByField0={countTableStackBy0}
stackByField1={countTableStackBy1}
@ -186,7 +211,11 @@ const ChartPanelsComponent: React.FC<Props> = ({
query={query}
setIsPanelExpanded={setIsTreemapPanelExpanded}
setStackByField0={updateCommonStackBy0}
setStackByField0ComboboxInputRef={setStackByField0ComboboxInputRef}
stackByField0ComboboxRef={stackByField0ComboboxRef}
setStackByField1={updateCommonStackBy1}
setStackByField1ComboboxInputRef={setStackByField1ComboboxInputRef}
stackByField1ComboboxRef={stackByField1ComboboxRef}
signalIndexName={signalIndexName}
stackByField0={riskChartStackBy0}
stackByField1={riskChartStackBy1}