[Security Solution] Feedback for Alert Page Filters (#154342)

Below features should be added to Alert Page Filters.(Fixes #154044)

- Design Feedback
   - Remove `Save Controls` from Context Menu ( Overflow Menu )
      |Before|After|
      |:---:|:---:|

|![image](230332279-1c6c6137-4767-4bb5-a472-e5fa527cd004.png)|
   - Move `Discard Changes -> Context Menu`.
     |Before|After|
     |:---:|:---:|

|![image](230332196-6dd45337-eb91-411c-bcf8-20b272db1d27.png)|
- When Redirected, do not enter in the Edit Mode and give option of
Saving/Discarding changes to the user in the Callout itself.
     |Before|After|
     |:---:|:---:|

|![image](230331787-72995324-ad62-4def-a834-de7564389288.png)|
- Functionality Feedback
- Status should always be part of page filters. User should not be able
to remove it.
- Currently users have ability to delete all controls, to persist some
pre-defined controls, but whenever users delete those, we add them back
when users save the controls.
         |Before|After|
         |:---:|:---:|
| <video
src="https://user-images.githubusercontent.com/7485038/230329833-1dd698d8-6bae-42f9-9133-168b2789f5ff.mov"/>
|<video
src="https://user-images.githubusercontent.com/7485038/230331343-e372da0a-dcd6-4d9d-a5f7-491b78c72998.mov"
/>|
- Raised the issue with `kibana-presentation` team to disabled `delete`
action for a particular controls.
https://github.com/elastic/kibana/issues/154068
This commit is contained in:
Jatin Kathuria 2023-04-17 14:20:58 +02:00 committed by GitHub
parent 54646146d7
commit 99db75512a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 198 additions and 85 deletions

View file

@ -492,6 +492,7 @@ export const DEFAULT_DETECTION_PAGE_FILTERS = [
fieldName: 'kibana.alert.workflow_status',
selectedOptions: ['open'],
hideActionBar: true,
persist: true,
},
{
title: 'Severity',

View file

@ -167,8 +167,8 @@ describe('Entity Analytics Dashboard', () => {
cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status');
cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open');
cy.get(OPTION_LIST_LABELS).eq(3).should('include.text', 'Host');
cy.get(OPTION_LIST_VALUES(3)).should('include.text', SIEM_KIBANA_HOST_NAME);
cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'Host');
cy.get(OPTION_LIST_VALUES(1)).should('include.text', SIEM_KIBANA_HOST_NAME);
});
});
});
@ -232,8 +232,8 @@ describe('Entity Analytics Dashboard', () => {
cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status');
cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open');
cy.get(OPTION_LIST_LABELS).eq(2).should('include.text', 'User');
cy.get(OPTION_LIST_VALUES(2)).should('include.text', TEST_USER_NAME);
cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'User');
cy.get(OPTION_LIST_VALUES(1)).should('include.text', TEST_USER_NAME);
});
});
});

View file

@ -11,7 +11,6 @@ import {
CONTROL_FRAMES,
CONTROL_FRAME_TITLE,
FILTER_GROUP_CHANGED_BANNER,
FILTER_GROUP_DISCARD_CHANGES,
FILTER_GROUP_SAVE_CHANGES_POPOVER,
OPTION_LIST_LABELS,
OPTION_LIST_VALUES,
@ -127,7 +126,7 @@ describe.skip('Detections : Page Filters', { testIsolation: false }, () => {
});
cy.get(CONTROL_FRAME_TITLE).should('contain.text', label);
cy.get(FILTER_GROUP_SAVE_CHANGES_POPOVER).should('be.visible');
cy.get(FILTER_GROUP_DISCARD_CHANGES).click();
discardFilterGroupControls();
cy.get(CONTROL_FRAME_TITLE).should('not.contain.text', label);
});
it('Delete Controls', () => {
@ -137,7 +136,7 @@ describe.skip('Detections : Page Filters', { testIsolation: false }, () => {
cy.get(CONTROL_FRAMES).should((sub) => {
expect(sub.length).lt(4);
});
cy.get(FILTER_GROUP_DISCARD_CHANGES).trigger('click', { force: true });
discardFilterGroupControls();
});
it('should not sync to the URL in edit mode but only in view mode', () => {
cy.url().then((urlString) => {
@ -148,16 +147,17 @@ describe.skip('Detections : Page Filters', { testIsolation: false }, () => {
cy.url().should('not.eq', urlString);
});
});
it('should not sync to the localstorage', () => {});
});
it('Page filters are loaded with custom values provided in the URL', () => {
const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.map((filter) => {
if (filter.title === 'Status') {
filter.selectedOptions = ['open', 'acknowledged'];
const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.filter((item) => item.persist).map(
(filter) => {
if (filter.title === 'Status') {
filter.selectedOptions = ['open', 'acknowledged'];
}
return filter;
}
return filter;
});
);
cy.url().then((url) => {
const currURL = new URL(url);
@ -188,7 +188,7 @@ describe.skip('Detections : Page Filters', { testIsolation: false }, () => {
waitForAlerts();
cy.get(OPTION_LIST_LABELS).should((sub) => {
DEFAULT_DETECTION_PAGE_FILTERS.forEach((filter, idx) => {
DEFAULT_DETECTION_PAGE_FILTERS.filter((item) => item.persist).forEach((filter, idx) => {
if (idx === DEFAULT_DETECTION_PAGE_FILTERS.length - 1) {
expect(sub.eq(idx).text()).eq(CUSTOM_URL_FILTER[0].title);
} else {
@ -198,7 +198,7 @@ describe.skip('Detections : Page Filters', { testIsolation: false }, () => {
});
});
cy.get(FILTER_GROUP_SAVE_CHANGES_POPOVER).should('be.visible');
cy.get(FILTER_GROUP_CHANGED_BANNER).should('be.visible');
});
it(`Alert list is updated when the alerts are updated`, () => {

View file

@ -49,6 +49,9 @@ export const FILTER_GROUP_CONTEXT_EDIT_CONTROLS = '[data-test-subj="filter_group
export const FILTER_GROUP_CONTEXT_SAVE_CONTROLS = '[data-test-subj="filter_group__context--save"]';
export const FILTER_GROUP_CONTEXT_DISCARD_CHANGES =
'[data-test-subj="filter_group__context--discard"]';
export const FILTER_GROUP_ADD_CONTROL = '[data-test-subj="filter-group__add-control"]';
export const FILTER_GROUP_SAVE_CHANGES = '[data-test-subj="filter-group__save"]';

View file

@ -9,7 +9,6 @@ import {
DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU,
DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON,
FILTER_GROUP_ADD_CONTROL,
FILTER_GROUP_DISCARD_CHANGES,
FILTER_GROUP_CONTEXT_EDIT_CONTROLS,
FILTER_GROUP_EDIT_CONTROLS_PANEL,
FILTER_GROUP_EDIT_CONTROL_PANEL_ITEMS,
@ -22,6 +21,7 @@ import {
DETECTION_PAGE_FILTER_GROUP_LOADING,
DETECTION_PAGE_FILTERS_LOADING,
OPTION_LISTS_LOADING,
FILTER_GROUP_CONTEXT_DISCARD_CHANGES,
} from '../../screens/common/filter_group';
import { waitForPageFilters } from '../alerts';
@ -55,8 +55,8 @@ export const saveFilterGroupControls = () => {
};
export const discardFilterGroupControls = () => {
cy.get(FILTER_GROUP_DISCARD_CHANGES).trigger('click');
cy.get(FILTER_GROUP_DISCARD_CHANGES).should('not.exist');
openFilterGroupContextMenu();
cy.get(FILTER_GROUP_CONTEXT_DISCARD_CHANGES).trigger('click', { force: true });
};
export const openAddFilterGroupControlPanel = () => {

View file

@ -10,23 +10,31 @@ import React from 'react';
import type { EuiButtonIconProps } from '@elastic/eui';
import { EuiButtonIcon, EuiCallOut, EuiPopover, EuiToolTip } from '@elastic/eui';
import { useFilterGroupInternalContext } from './hooks/use_filters';
import { DISCARD_CHANGES, PENDING_CHANGES_REMINDER } from './translations';
import {
ADD_CONTROLS,
ADD_CONTROLS_MAX_LIMIT,
DISCARD_CHANGES,
PENDING_CHANGES_REMINDER,
} from './translations';
interface AddControlProps extends Partial<EuiButtonIconProps> {
onClick: () => void;
}
export const AddControl: FC<AddControlProps> = ({ onClick, ...rest }) => {
const { isDisabled } = rest;
return (
<EuiButtonIcon
size="s"
iconSize="m"
display="base"
data-test-subj={'filter-group__add-control'}
onClick={onClick}
{...rest}
iconType={'plusInCircle'}
/>
<EuiToolTip content={isDisabled ? ADD_CONTROLS_MAX_LIMIT : ADD_CONTROLS}>
<EuiButtonIcon
size="s"
iconSize="m"
display="base"
data-test-subj={'filter-group__add-control'}
onClick={onClick}
{...rest}
iconType={'plusInCircle'}
/>
</EuiToolTip>
);
};

View file

@ -6,6 +6,6 @@
*/
export const NUM_OF_CONTROLS = {
MIN: 2,
MAX: 6,
MIN: 0,
MAX: 4,
};

View file

@ -11,9 +11,9 @@ import { useFilterGroupInternalContext } from './hooks/use_filters';
import {
CONTEXT_MENU_RESET,
CONTEXT_MENU_RESET_TOOLTIP,
DISCARD_CHANGES,
EDIT_CONTROLS,
FILTER_GROUP_MENU,
SAVE_CONTROLS,
} from './translations';
export const FilterGroupContextMenu = () => {
@ -28,6 +28,7 @@ export const FilterGroupContextMenu = () => {
initialControls,
dataViewId,
setShowFiltersChangedBanner,
discardChangesHandler,
} = useFilterGroupInternalContext();
const toggleContextMenu = useCallback(() => {
@ -97,18 +98,20 @@ export const FilterGroupContextMenu = () => {
const editControlsButton = useMemo(
() => (
<EuiContextMenuItem
icon="pencil"
icon={isViewMode ? 'pencil' : 'minusInCircle'}
onClick={
isViewMode
? withContextMenuAction(switchToEditMode)
: withContextMenuAction(switchToViewMode)
: withContextMenuAction(discardChangesHandler)
}
data-test-subj={
isViewMode ? `filter_group__context--edit` : `filter_group__context--discard`
}
data-test-subj={isViewMode ? `filter_group__context--edit` : `filter_group__context--save`}
>
{isViewMode ? EDIT_CONTROLS : SAVE_CONTROLS}
{isViewMode ? EDIT_CONTROLS : DISCARD_CHANGES}
</EuiContextMenuItem>
),
[withContextMenuAction, isViewMode, switchToEditMode, switchToViewMode]
[withContextMenuAction, isViewMode, switchToEditMode, discardChangesHandler]
);
const contextMenuItems = useMemo(

View file

@ -23,6 +23,8 @@ export interface FilterGroupContextType {
switchToEditMode: () => void;
setHasPendingChanges: (value: boolean) => void;
setShowFiltersChangedBanner: (value: boolean) => void;
saveChangesHandler: () => void;
discardChangesHandler: () => void;
}
export const FilterGroupContext = createContext<FilterGroupContextType | undefined>(undefined);

View file

@ -5,11 +5,25 @@
* 2.0.
*/
import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { FC } from 'react';
import React from 'react';
import { FILTER_GROUP_BANNER_MESSAGE, FILTER_GROUP_BANNER_TITLE } from './translations';
import {
FILTER_GROUP_BANNER_MESSAGE,
FILTER_GROUP_BANNER_TITLE,
REVERT_CHANGES,
SAVE_CHANGES,
} from './translations';
export const FiltersChangedBanner = () => {
interface FiltersChangesBanner {
saveChangesHandler: () => void;
discardChangesHandler: () => void;
}
export const FiltersChangedBanner: FC<FiltersChangesBanner> = ({
saveChangesHandler,
discardChangesHandler,
}) => {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s">
<EuiFlexItem grow={true}>
@ -19,6 +33,16 @@ export const FiltersChangedBanner = () => {
iconType={'iInCircle'}
>
<p>{FILTER_GROUP_BANNER_MESSAGE}</p>
<EuiButton
data-test-subj="filter-group__save"
color={'primary'}
onClick={saveChangesHandler}
>
{SAVE_CHANGES}
</EuiButton>
<EuiButtonEmpty data-test-subj="filter-group__discard" onClick={discardChangesHandler}>
{REVERT_CHANGES}
</EuiButtonEmpty>
</EuiCallOut>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,6 +6,7 @@
*/
import type { Filter } from '@kbn/es-query';
import type { ControlPanelState, OptionsListEmbeddableInput } from '@kbn/controls-plugin/common';
import type {
ControlGroupInput,
controlGroupInputBuilder,
@ -30,14 +31,14 @@ import { APP_ID } from '../../../../common/constants';
import './index.scss';
import { FilterGroupLoading } from './loading';
import { withSpaceId } from '../with_space_id';
import { NUM_OF_CONTROLS } from './config';
import { useControlGroupSyncToLocalStorage } from './hooks/use_control_group_sync_to_local_storage';
import { useViewEditMode } from './hooks/use_view_edit_mode';
import { FilterGroupContextMenu } from './context_menu';
import { AddControl, DiscardChanges, SaveControls } from './buttons';
import { AddControl, SaveControls } from './buttons';
import { getFilterItemObjListFromControlInput } from './utils';
import { FiltersChangedBanner } from './filters_changed_banner';
import { FilterGroupContext } from './filter_group_context';
import { NUM_OF_CONTROLS } from './config';
type ControlGroupBuilder = typeof controlGroupInputBuilder;
@ -212,8 +213,10 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
*
* */
const localInitialControls = cloneDeep(initialControls);
const resultControls = cloneDeep(initialControls);
const localInitialControls = cloneDeep(initialControls).filter(
(control) => control.persist === true
);
let resultControls = [] as FilterItemObj[];
let overridingControls = initialUrlParam;
if (!initialUrlParam || initialUrlParam.length === 0) {
@ -231,40 +234,28 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
// if initialUrlParam Exists... replace localInitialControls with what was provided in the Url
if (overridingControls && !urlDataApplied.current) {
let maxInitialControlIdx = Math.max(
localInitialControls.length - 1,
(overridingControls?.length ?? 1) - 1
);
for (let counter = overridingControls.length - 1; counter >= 0; counter--) {
const urlControl = overridingControls[counter];
const idx = localInitialControls.findIndex(
(item) => item.fieldName === urlControl.fieldName
);
if (localInitialControls.length > 0) {
localInitialControls.forEach((persistControl) => {
const doesPersistControlAlreadyExist = overridingControls?.some(
(control) => control.fieldName === persistControl.fieldName
);
if (idx !== -1) {
// if index found, replace that with what was provided in the Url
resultControls[idx] = {
...localInitialControls[idx],
fieldName: urlControl.fieldName,
title: urlControl.title ?? urlControl.fieldName,
selectedOptions: urlControl.selectedOptions ?? [],
existsSelected: urlControl.existsSelected ?? false,
exclude: urlControl.exclude ?? false,
};
} else {
// if url param is not available in initialControl, start replacing the last slot in the
// initial Control with the last `not found` element in the Url Param
//
resultControls[maxInitialControlIdx] = {
fieldName: urlControl.fieldName,
selectedOptions: urlControl.selectedOptions ?? [],
title: urlControl.title ?? urlControl.fieldName,
existsSelected: urlControl.existsSelected ?? false,
exclude: urlControl.exclude ?? false,
};
maxInitialControlIdx--;
}
if (!doesPersistControlAlreadyExist) {
resultControls.push(persistControl);
}
});
}
resultControls = [
...resultControls,
...overridingControls.map((item) => ({
fieldName: item.fieldName,
title: item.title,
selectedOptions: item.selectedOptions ?? [],
existsSelected: item.existsSelected ?? false,
exclude: item.existsSelected,
})),
];
}
return resultControls;
@ -333,10 +324,58 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
setShowFiltersChangedBanner(false);
}, [controlGroup, switchToViewMode, getStoredControlInput, hasPendingChanges]);
const upsertPersistableControls = useCallback(() => {
const persistableControls = initialControls.filter((control) => control.persist === true);
if (persistableControls.length > 0) {
const currentPanels = Object.values(controlGroup?.getInput().panels ?? []) as Array<
ControlPanelState<OptionsListEmbeddableInput>
>;
const orderedPanels = currentPanels.sort((a, b) => a.order - b.order);
let filterControlsDeleted = false;
persistableControls.forEach((control) => {
const controlExists = currentPanels.some(
(currControl) => control.fieldName === currControl.explicitInput.fieldName
);
if (!controlExists) {
// delete current controls
if (!filterControlsDeleted) {
controlGroup?.updateInput({ panels: {} });
filterControlsDeleted = true;
}
// add persitable controls
controlGroup?.addOptionsListControl({
title: control.title,
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
selectedOptions: control.selectedOptions,
...control,
});
}
});
orderedPanels.forEach((panel) => {
if (panel.explicitInput.fieldName)
controlGroup?.addOptionsListControl({
selectedOptions: [],
fieldName: panel.explicitInput.fieldName,
dataViewId: dataViewId ?? '',
...panel.explicitInput,
});
});
}
}, [controlGroup, dataViewId, initialControls]);
const saveChangesHandler = useCallback(() => {
upsertPersistableControls();
switchToViewMode();
setShowFiltersChangedBanner(false);
}, [switchToViewMode]);
}, [switchToViewMode, upsertPersistableControls]);
const addControlsHandler = useCallback(() => {
controlGroup?.openAddDataControlFlyout();
@ -358,6 +397,8 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
openPendingChangesPopover,
closePendingChangesPopover,
setShowFiltersChangedBanner,
saveChangesHandler,
discardChangesHandler,
}}
>
<FilterWrapper className="filter-group__wrapper">
@ -371,19 +412,19 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
{!controlGroup ? <FilterGroupLoading /> : null}
</EuiFlexItem>
) : null}
{!isViewMode &&
(Object.keys(controlGroupInputUpdates?.panels ?? {}).length > NUM_OF_CONTROLS.MIN ||
Object.keys(controlGroupInputUpdates?.panels ?? {}).length < NUM_OF_CONTROLS.MAX) ? (
{!isViewMode && !showFiltersChangedBanner ? (
<>
<EuiFlexItem grow={false}>
<AddControl onClick={addControlsHandler} />
<AddControl
onClick={addControlsHandler}
isDisabled={
Object.values(controlGroupInputUpdates.panels).length >= NUM_OF_CONTROLS.MAX
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SaveControls onClick={saveChangesHandler} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DiscardChanges onClick={discardChangesHandler} />
</EuiFlexItem>
</>
) : null}
<EuiFlexItem grow={false}>
@ -393,7 +434,10 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
{showFiltersChangedBanner ? (
<>
<EuiSpacer size="l" />
<FiltersChangedBanner />
<FiltersChangedBanner
saveChangesHandler={saveChangesHandler}
discardChangesHandler={discardChangesHandler}
/>
</>
) : null}
</FilterWrapper>

View file

@ -21,6 +21,13 @@ export const EDIT_CONTROLS = i18n.translate(
}
);
export const ADD_CONTROLS = i18n.translate(
'xpack.securitySolution.filtersGroup.contextMenu.addControls',
{
defaultMessage: 'Add Controls',
}
);
export const SAVE_CONTROLS = i18n.translate(
'xpack.securitySolution.filtersGroup.contextMenu.saveControls',
{
@ -70,3 +77,24 @@ export const CONTEXT_MENU_RESET = i18n.translate(
defaultMessage: 'Reset Controls',
}
);
export const SAVE_CHANGES = i18n.translate(
'xpack.securitySolution.filtersGroup.contextMenu.saveChanges',
{
defaultMessage: 'Save Changes',
}
);
export const REVERT_CHANGES = i18n.translate(
'xpack.securitySolution.filtersGroup.contextMenu.revertChanges',
{
defaultMessage: 'Revert Changes',
}
);
export const ADD_CONTROLS_MAX_LIMIT = i18n.translate(
'xpack.securitySolution.filtersGroup.contextMenu.addControls.maxLimit',
{
defaultMessage: 'Maximum of 4 controls can be added.',
}
);

View file

@ -33,7 +33,7 @@ export type FilterGroupHandler = ControlGroupContainer;
export type FilterGroupProps = {
dataViewId: string | null;
onFilterChange?: (newFilters: Filter[]) => void;
initialControls: FilterItemObj[];
initialControls: Array<FilterItemObj & { persist?: boolean }>;
spaceId: string;
onInit?: (controlGroupHandler: FilterGroupHandler | undefined) => void;
} & Pick<ControlGroupInput, 'timeRange' | 'filters' | 'query' | 'chainingSystem'>;