mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
287e3b8bad
commit
792ddae2d5
11 changed files with 392 additions and 50 deletions
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue