[8.9] [Security Solution][Fix] Alert Page Filter Controls should not ignore Invalid Selections (#160374) (#161208)

# Backport

This will backport the following commits from `main` to `8.9`:
- [[Security Solution][Fix] Alert Page Filter Controls should not ignore
Invalid Selections
(#160374)](https://github.com/elastic/kibana/pull/160374)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Jatin
Kathuria","email":"jatin.kathuria@elastic.co"},"sourceCommit":{"committedDate":"2023-07-04T12:13:54Z","message":"[Security
Solution][Fix] Alert Page Filter Controls should not ignore Invalid
Selections (#160374)\n\nThis PR changes how Alert Page Filter Controls
Work. \r\n\r\n## Before\r\n\r\n1. Filter Controls use to ignore invalid
Selections. For example, if\r\nUser has selected `Open` as the filter,
but there is actually no alert\r\nwith Status `Open`, filters would
ignore that selection and would\r\nproceed to show other alerts ( which
are NOT `Open`) .\r\n\r\n- It seemed it was confusing users.
[This\r\nbug](https://github.com/elastic/kibana/issues/159606) and
[messages
in\r\ncommunity\r\nslack](https://elasticstack.slack.com/archives/CNRTGB9A4/p1686937708085629?thread_ts=1686841414.978319&cid=CNRTGB9A4)\r\nare
the examples. @paulewing also emphasized this.\r\n\r\n - Below video
shows what I mean.\r\n
\r\n\r\n01771587-e4e8-4331-9535-4ffa09877c02\r\n\r\n\r\n\r\n\r\n\r\n\r\n##
After\r\n\r\n1. With this Change Control Filters no longer ignore
invalid selection.\r\nSo if user has chosen to show only `Open` Alerts.
Then that filter will\r\nbe taken into account even though no alert with
`Open` exists and a\r\nempty table will be show.\r\n\r\n - Here is a
video to demonstrate it.\r\n
\r\n\r\n62b17762-16c0-471f-8480-e9f46e2ca5ef","sha":"54e613b1d9341497d40a2c671c527de18beec165","branchLabelMapping":{"^v8.10.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Threat
Hunting:Investigations","ci:cloud-deploy","v8.9.0","v8.10.0"],"number":160374,"url":"https://github.com/elastic/kibana/pull/160374","mergeCommit":{"message":"[Security
Solution][Fix] Alert Page Filter Controls should not ignore Invalid
Selections (#160374)\n\nThis PR changes how Alert Page Filter Controls
Work. \r\n\r\n## Before\r\n\r\n1. Filter Controls use to ignore invalid
Selections. For example, if\r\nUser has selected `Open` as the filter,
but there is actually no alert\r\nwith Status `Open`, filters would
ignore that selection and would\r\nproceed to show other alerts ( which
are NOT `Open`) .\r\n\r\n- It seemed it was confusing users.
[This\r\nbug](https://github.com/elastic/kibana/issues/159606) and
[messages
in\r\ncommunity\r\nslack](https://elasticstack.slack.com/archives/CNRTGB9A4/p1686937708085629?thread_ts=1686841414.978319&cid=CNRTGB9A4)\r\nare
the examples. @paulewing also emphasized this.\r\n\r\n - Below video
shows what I mean.\r\n
\r\n\r\n01771587-e4e8-4331-9535-4ffa09877c02\r\n\r\n\r\n\r\n\r\n\r\n\r\n##
After\r\n\r\n1. With this Change Control Filters no longer ignore
invalid selection.\r\nSo if user has chosen to show only `Open` Alerts.
Then that filter will\r\nbe taken into account even though no alert with
`Open` exists and a\r\nempty table will be show.\r\n\r\n - Here is a
video to demonstrate it.\r\n
\r\n\r\n62b17762-16c0-471f-8480-e9f46e2ca5ef","sha":"54e613b1d9341497d40a2c671c527de18beec165"}},"sourceBranch":"main","suggestedTargetBranches":["8.9"],"targetPullRequestStates":[{"branch":"8.9","label":"v8.9.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.10.0","labelRegex":"^v8.10.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/160374","number":160374,"mergeCommit":{"message":"[Security
Solution][Fix] Alert Page Filter Controls should not ignore Invalid
Selections (#160374)\n\nThis PR changes how Alert Page Filter Controls
Work. \r\n\r\n## Before\r\n\r\n1. Filter Controls use to ignore invalid
Selections. For example, if\r\nUser has selected `Open` as the filter,
but there is actually no alert\r\nwith Status `Open`, filters would
ignore that selection and would\r\nproceed to show other alerts ( which
are NOT `Open`) .\r\n\r\n- It seemed it was confusing users.
[This\r\nbug](https://github.com/elastic/kibana/issues/159606) and
[messages
in\r\ncommunity\r\nslack](https://elasticstack.slack.com/archives/CNRTGB9A4/p1686937708085629?thread_ts=1686841414.978319&cid=CNRTGB9A4)\r\nare
the examples. @paulewing also emphasized this.\r\n\r\n - Below video
shows what I mean.\r\n
\r\n\r\n01771587-e4e8-4331-9535-4ffa09877c02\r\n\r\n\r\n\r\n\r\n\r\n\r\n##
After\r\n\r\n1. With this Change Control Filters no longer ignore
invalid selection.\r\nSo if user has chosen to show only `Open` Alerts.
Then that filter will\r\nbe taken into account even though no alert with
`Open` exists and a\r\nempty table will be show.\r\n\r\n - Here is a
video to demonstrate it.\r\n
\r\n\r\n62b17762-16c0-471f-8480-e9f46e2ca5ef","sha":"54e613b1d9341497d40a2c671c527de18beec165"}}]}]
BACKPORT-->
This commit is contained in:
Jatin Kathuria 2023-07-05 01:29:50 -07:00 committed by GitHub
parent 4af53fe273
commit 6c673240ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 616 additions and 208 deletions

View file

@ -60,6 +60,7 @@ const uploadPipeline = (pipelineContent: string | object) => {
if (
(await doAnyChangesMatch([
/^src\/plugins\/controls/,
/^packages\/kbn-securitysolution-.*/,
/^x-pack\/plugins\/lists/,
/^x-pack\/plugins\/security_solution/,

View file

@ -6,6 +6,7 @@
*/
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public';
import * as i18n from './translations';
/**
@ -503,19 +504,23 @@ export const MAX_NUMBER_OF_NEW_TERMS_FIELDS = 3;
export const BULK_ADD_TO_TIMELINE_LIMIT = 2000;
export const DEFAULT_DETECTION_PAGE_FILTERS = [
export const DEFAULT_DETECTION_PAGE_FILTERS: Array<
Omit<AddOptionsListControlProps, 'dataViewId'> & { persist?: boolean }
> = [
{
title: 'Status',
fieldName: 'kibana.alert.workflow_status',
selectedOptions: ['open'],
hideActionBar: true,
persist: true,
hideExists: true,
},
{
title: 'Severity',
fieldName: 'kibana.alert.severity',
selectedOptions: [],
hideActionBar: true,
hideExists: true,
},
{
title: 'User',

View file

@ -6,13 +6,13 @@
*/
import { encode } from '@kbn/rison';
import type { FilterItemObj } from '../../../../public/common/components/filter_group/types';
import { getNewRule } from '../../../objects/rule';
import {
CONTROL_FRAMES,
CONTROL_FRAME_TITLE,
CONTROL_POPOVER,
FILTER_GROUP_CHANGED_BANNER,
OPTION_IGNORED,
OPTION_LIST_LABELS,
OPTION_LIST_VALUES,
OPTION_SELECTABLE,
@ -29,7 +29,6 @@ import { formatPageFilterSearchParam } from '../../../../common/utils/format_pag
import {
closePageFilterPopover,
markAcknowledgedFirstAlert,
openFirstAlert,
openPageFilterPopover,
resetFilters,
selectCountTable,
@ -38,7 +37,7 @@ import {
waitForAlerts,
waitForPageFilters,
} from '../../../tasks/alerts';
import { ALERTS_COUNT, ALERTS_REFRESH_BTN } from '../../../screens/alerts';
import { ALERTS_COUNT, ALERTS_REFRESH_BTN, EMPTY_ALERT_TABLE } from '../../../screens/alerts';
import { kqlSearch, navigateFromHeaderTo } from '../../../tasks/security_header';
import { ALERTS, CASES } from '../../../screens/security_header';
import {
@ -84,7 +83,9 @@ const customFilters = [
title: 'Rule Name',
},
];
const assertFilterControlsWithFilterObject = (filterObject = DEFAULT_DETECTION_PAGE_FILTERS) => {
const assertFilterControlsWithFilterObject = (
filterObject: FilterItemObj[] = DEFAULT_DETECTION_PAGE_FILTERS
) => {
cy.get(CONTROL_FRAMES).should((sub) => {
expect(sub.length).eq(filterObject.length);
});
@ -97,18 +98,16 @@ const assertFilterControlsWithFilterObject = (filterObject = DEFAULT_DETECTION_P
filterObject.forEach((filter, idx) => {
cy.get(OPTION_LIST_VALUES(idx)).should((sub) => {
expect(sub.text().replace(',', '')).satisfy((txt: string) => {
return txt.startsWith(
filter.selectedOptions && filter.selectedOptions.length > 0
? filter.selectedOptions.join('')
: ''
);
});
const selectedOptionsText =
filter.selectedOptions && filter.selectedOptions.length > 0
? filter.selectedOptions.join('')
: '';
expect(sub.text().replace(',', '').replace(' ', '')).to.have.string(selectedOptionsText);
});
});
};
describe('Detections : Page Filters', () => {
describe(`Detections : Page Filters`, () => {
before(() => {
cleanKibana();
createRule(getNewRule({ rule_id: 'custom_rule_filters' }));
@ -130,6 +129,9 @@ describe('Detections : Page Filters', () => {
login();
visit(ALERTS_URL);
waitForAlerts();
});
afterEach(() => {
resetFilters();
});
@ -183,10 +185,11 @@ describe('Detections : Page Filters', () => {
it('Page filters are loaded with custom values provided in the URL', () => {
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,
selectedOptions:
filter.title === 'Status' ? ['open', 'acknowledged'] : filter.selectedOptions,
};
}
);
@ -232,41 +235,39 @@ describe('Detections : Page Filters', () => {
cy.get(FILTER_GROUP_CHANGED_BANNER).should('be.visible');
});
// TODO https://github.com/elastic/kibana/issues/160980
it.skip(`Alert list is updated when the alerts are updated`, () => {
// mark status of one alert to be acknowledged
selectCountTable();
cy.get(ALERTS_COUNT)
.invoke('text')
.then((noOfAlerts) => {
const originalAlertCount = noOfAlerts.split(' ')[0];
markAcknowledgedFirstAlert();
waitForAlerts();
cy.get(OPTION_LIST_VALUES(0)).click();
cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible').trigger('click');
cy.get(ALERTS_COUNT)
.invoke('text')
.should((newAlertCount) => {
expect(newAlertCount.split(' ')[0]).eq(String(parseInt(originalAlertCount, 10) - 1));
});
});
context('with data modificiation', () => {
after(() => {
cleanKibana();
createRule(getNewRule({ rule_id: 'custom_rule_filters' }));
});
// cleanup
// revert the changes so that data does not change for further tests.
// It would make sure that tests can run in any order.
cy.get(OPTION_SELECTABLE(0, 'open')).trigger('click');
togglePageFilterPopover(0);
openFirstAlert();
waitForAlerts();
it(`Alert list is updated when the alerts are updated`, () => {
// mark status of one alert to be acknowledged
selectCountTable();
cy.get(ALERTS_COUNT)
.invoke('text')
.then((noOfAlerts) => {
const originalAlertCount = noOfAlerts.split(' ')[0];
markAcknowledgedFirstAlert();
waitForAlerts();
cy.get(OPTION_LIST_VALUES(0)).click();
cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible').trigger('click');
cy.get(ALERTS_COUNT)
.invoke('text')
.should((newAlertCount) => {
expect(newAlertCount.split(' ')[0]).eq(String(parseInt(originalAlertCount, 10) - 1));
});
});
});
});
it(`URL is updated when filters are updated`, () => {
cy.on('url:changed', (urlString) => {
const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.map((filter) => {
if (filter.title === 'Severity') {
filter.selectedOptions = ['high'];
}
return filter;
return {
...filter,
selectedOptions: filter.title === 'Severity' ? ['high'] : filter.selectedOptions,
};
});
const expectedVal = encode(formatPageFilterSearchParam(NEW_FILTERS));
expect(urlString).to.contain.text(expectedVal);
@ -334,8 +335,7 @@ describe('Detections : Page Filters', () => {
resetFilters();
});
// TODO https://github.com/elastic/kibana/issues/160980
it.skip('should recover from invalide kql Query result', () => {
it('should recover from invalid kql Query result', () => {
// do an invalid search
//
kqlSearch('\\');
@ -354,7 +354,7 @@ describe('Detections : Page Filters', () => {
waitForPageFilters();
togglePageFilterPopover(0);
cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found');
cy.get(OPTION_IGNORED(0, 'open')).should('be.visible');
cy.get(EMPTY_ALERT_TABLE).should('be.visible');
});
it('should take filters into account', () => {
@ -366,7 +366,7 @@ describe('Detections : Page Filters', () => {
waitForPageFilters();
togglePageFilterPopover(0);
cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found');
cy.get(OPTION_IGNORED(0, 'open')).should('be.visible');
cy.get(EMPTY_ALERT_TABLE).should('be.visible');
});
it('should take timeRange into account', () => {
const startDateWithZeroAlerts = 'Jan 1, 2002 @ 00:00:00.000';
@ -379,7 +379,7 @@ describe('Detections : Page Filters', () => {
waitForPageFilters();
togglePageFilterPopover(0);
cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found');
cy.get(OPTION_IGNORED(0, 'open')).should('be.visible');
cy.get(EMPTY_ALERT_TABLE).should('be.visible');
});
});
it('Number fields are not visible in field edit panel', () => {

View file

@ -21,6 +21,8 @@ export const OPTION_LIST_LABELS = '.controlFrame__labelToolTip';
export const OPTION_LIST_VALUES = (idx: number) => `[data-test-subj="optionsList-control-${idx}"]`;
export const OPTION_LIST_CLEAR_BTN = '.presentationUtil__floatingActions [aria-label="Clear"]';
export const OPTION_LIST_NUMBER_OFF = '.euiFilterButton__notification';
export const OPTION_LISTS_LOADING = '.optionsList--filterBtnWrapper .euiLoadingSpinner';
@ -48,7 +50,11 @@ export const DETECTION_PAGE_FILTERS_LOADING = '.securityPageWrapper .controlFram
export const DETECTION_PAGE_FILTER_GROUP_LOADING = '[data-test-subj="filter-group__loading"]';
export const DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU = '[data-test-subj="filter-group__context"]';
export const DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU_BTN =
'[data-test-subj="filter-group__context"]';
export const DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU =
'[data-test-subj="filter-group__context-menu"]';
export const DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON =
'[data-test-subj="filter-group__context--reset"]';

View file

@ -6,6 +6,7 @@
*/
import { encode } from '@kbn/rison';
import { recurse } from 'cypress-recurse';
import { formatPageFilterSearchParam } from '../../common/utils/format_page_filter_search_param';
import { TOP_N_CONTAINER } from '../screens/network/flows';
import {
@ -63,14 +64,14 @@ import {
} from '../screens/alerts_details';
import { FIELD_INPUT } from '../screens/exceptions';
import {
CONTROL_FRAME_TITLE,
DETECTION_PAGE_FILTERS_LOADING,
DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU,
DETECTION_PAGE_FILTER_GROUP_LOADING,
DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON,
DETECTION_PAGE_FILTER_GROUP_WRAPPER,
OPTION_LISTS_EXISTS,
OPTION_LISTS_LOADING,
OPTION_LIST_VALUES,
OPTION_LIST_CLEAR_BTN,
OPTION_SELECTABLE,
} from '../screens/common/filter_group';
import { LOADING_SPINNER } from '../screens/common/page';
@ -78,6 +79,7 @@ import { ALERTS_URL } from '../urls/navigation';
import { FIELDS_BROWSER_BTN } from '../screens/rule_details';
import type { FilterItemObj } from '../../public/common/components/filter_group/types';
import { visit } from './login';
import { openFilterGroupContextMenu } from './common/filter_group';
export const addExceptionFromFirstAlert = () => {
expandFirstAlertActions();
@ -175,23 +177,21 @@ export const closePageFilterPopover = (filterIndex: number) => {
cy.get(OPTION_LIST_VALUES(filterIndex)).should('not.have.class', 'euiFilterButton-isSelected');
};
export const clearAllSelections = () => {
cy.get(OPTION_LISTS_EXISTS).click({ force: true });
cy.get(OPTION_LISTS_EXISTS).then(($el) => {
if ($el.attr('aria-checked', 'false')) {
// check it
$el.click();
}
// uncheck it
$el.click();
});
export const clearAllSelections = (filterIndex: number) => {
recurse(
() => {
cy.get(CONTROL_FRAME_TITLE).eq(filterIndex).realHover();
return cy.get(OPTION_LIST_CLEAR_BTN).eq(filterIndex);
},
($el) => $el.is(':visible')
);
cy.get(OPTION_LIST_CLEAR_BTN).eq(filterIndex).should('be.visible').trigger('click');
};
export const selectPageFilterValue = (filterIndex: number, ...values: string[]) => {
refreshAlertPageFilter();
clearAllSelections(filterIndex);
openPageFilterPopover(filterIndex);
clearAllSelections();
values.forEach((value) => {
cy.get(OPTION_SELECTABLE(filterIndex, value)).click({ force: true });
});
@ -407,9 +407,10 @@ export const waitForPageFilters = () => {
};
export const resetFilters = () => {
cy.get(DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU).click({ force: true });
cy.get(DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON).click({ force: true });
openFilterGroupContextMenu();
cy.get(DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON).trigger('click');
waitForPageFilters();
cy.log('Resetting filters complete');
};
export const parseAlertsCountToInt = (count: string | number) =>

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { recurse } from 'cypress-recurse';
import {
DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU,
DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU_BTN,
DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON,
FILTER_GROUP_ADD_CONTROL,
FILTER_GROUP_CONTEXT_EDIT_CONTROLS,
@ -23,11 +24,18 @@ import {
OPTION_LISTS_LOADING,
FILTER_GROUP_CONTEXT_DISCARD_CHANGES,
FILTER_GROUP_CONTROL_ACTION_EDIT,
DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU,
} from '../../screens/common/filter_group';
import { waitForPageFilters } from '../alerts';
export const openFilterGroupContextMenu = () => {
cy.get(DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU).click();
recurse(
() => {
cy.get(DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU_BTN).click();
return cy.get(DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU).should(Cypress._.noop);
},
($el) => $el.length === 1
);
};
export const waitForFilterGroups = () => {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public';
export const TEST_IDS = {
FILTER_CONTROLS: 'filter-group__items',
FILTER_LOADING: 'filter-group__loading',
@ -23,3 +25,20 @@ export const TEST_IDS = {
DISCARD: `filter-group__context--discard`,
},
};
export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial<AddOptionsListControlProps> = {
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
ignoreParentSettings: {
ignoreValidations: true,
},
};
export const TIMEOUTS = {
/* because of recent changes in controls-plugin debounce time may not be needed
* still keeping the config for some time for any recent changes
* */
FILTER_UPDATES_DEBOUNCE_TIME: 0,
};

View file

@ -7,7 +7,7 @@
import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { TEST_IDS } from './constants';
import { COMMON_OPTIONS_LIST_CONTROL_INPUTS, TEST_IDS } from './constants';
import { useFilterGroupInternalContext } from './hooks/use_filters';
import {
CONTEXT_MENU_RESET,
@ -60,10 +60,7 @@ export const FilterGroupContextMenu = () => {
const control = initialControls[counter];
await controlGroup?.addOptionsListControl({
controlId: String(counter),
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
@ -139,6 +136,9 @@ export const FilterGroupContextMenu = () => {
closePopover={toggleContextMenu}
panelPaddingSize="none"
anchorPosition="downLeft"
panelProps={{
'data-test-subj': TEST_IDS.CONTEXT_MENU.MENU,
}}
>
<EuiContextMenuPanel items={contextMenuItems} />
</EuiPopover>

View file

@ -26,7 +26,7 @@ import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import { initialInputData, sampleOutputData } from './mocks/data';
import { createStore } from '../../store';
import { useGetInitialUrlParamValue } from '../../utils/global_query_string/helpers';
import { TEST_IDS } from './constants';
import { COMMON_OPTIONS_LIST_CONTROL_INPUTS, TEST_IDS } from './constants';
import {
controlGroupFilterInputMock$,
controlGroupFilterOutputMock$,
@ -304,7 +304,7 @@ describe(' Filter Group Component ', () => {
);
});
it('should save controls successfully', async () => {
it('should not rebuild controls while saving controls when controls are in desired order', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
await openContextMenu();
@ -314,7 +314,9 @@ describe(' Filter Group Component ', () => {
const newInputData = {
...initialInputData,
panels: {
// status as persistent controls is first in the position with order as 0
'0': initialInputData.panels['0'],
'1': initialInputData.panels['1'],
},
} as ControlGroupInput;
@ -329,10 +331,51 @@ describe(' Filter Group Component ', () => {
// edit model gone
expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0);
// check if upsert was called correctely
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(1);
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(0);
});
});
it('should rebuild and save controls successfully when controls are not in desired order', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
await openContextMenu();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.EDIT));
// modify controls
const newInputData = {
...initialInputData,
panels: {
'0': {
...initialInputData.panels['0'],
// status is second in position.
// this will force the rebuilding of controls
order: 1,
},
'1': {
...initialInputData.panels['1'],
order: 0,
},
},
} as ControlGroupInput;
updateControlGroupInputMock(newInputData);
// clear any previous calls to the API
controlGroupMock.addOptionsListControl.mockClear();
fireEvent.click(screen.getByTestId(TEST_IDS.SAVE_CONTROL));
await waitFor(() => {
// edit model gone
expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0);
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
// field id is not required to be passed when creating a control
const { id, ...expectedInputData } = initialInputData.panels['0'].explicitInput;
expect(controlGroupMock.addOptionsListControl.mock.calls[0][0]).toMatchObject({
...initialInputData.panels['0'].explicitInput,
...expectedInputData,
});
});
});
@ -363,17 +406,18 @@ describe(' Filter Group Component ', () => {
await waitFor(() => {
// edit model gone
expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0);
// check if upsert was called correctely
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
expect(controlGroupMock.addOptionsListControl.mock.calls[0][0]).toMatchObject({
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
...DEFAULT_DETECTION_PAGE_FILTERS[0],
});
// field id is not required to be passed when creating a control
const { id, ...expectedInputData } = initialInputData.panels['3'].explicitInput;
expect(controlGroupMock.addOptionsListControl.mock.calls[1][0]).toMatchObject({
...initialInputData.panels['3'].explicitInput,
...expectedInputData,
});
});
});
@ -595,18 +639,12 @@ describe(' Filter Group Component ', () => {
updateControlGroupInputMock(initialInputData as ControlGroupInput);
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
expect(controlGroupMock.addOptionsListControl.mock.calls[0][1]).toMatchObject({
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
...DEFAULT_DETECTION_PAGE_FILTERS[0],
});
expect(controlGroupMock.addOptionsListControl.mock.calls[1][1]).toMatchObject({
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
fieldName: 'abc',
});

View file

@ -6,11 +6,7 @@
*/
import type { Filter } from '@kbn/es-query';
import type {
ControlInputTransform,
ControlPanelState,
OptionsListEmbeddableInput,
} from '@kbn/controls-plugin/common';
import type { ControlInputTransform } from '@kbn/controls-plugin/common';
import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import type {
ControlGroupInput,
@ -18,6 +14,7 @@ import type {
ControlGroupOutput,
ControlGroupContainer,
ControlGroupRendererProps,
DataControlInput,
} from '@kbn/controls-plugin/public';
import { ControlGroupRenderer } from '@kbn/controls-plugin/public';
import type { PropsWithChildren } from 'react';
@ -26,7 +23,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import type { Subscription } from 'rxjs';
import styled from 'styled-components';
import { cloneDeep, debounce, isEqual } from 'lodash';
import { debounce, isEqual, isEqualWith } from 'lodash';
import type {
ControlGroupCreationOptions,
FieldFilterPredicate,
@ -43,11 +40,16 @@ import { useControlGroupSyncToLocalStorage } from './hooks/use_control_group_syn
import { useViewEditMode } from './hooks/use_view_edit_mode';
import { FilterGroupContextMenu } from './context_menu';
import { AddControl, SaveControls } from './buttons';
import { getFilterItemObjListFromControlInput } from './utils';
import {
getFilterControlsComparator,
getFilterItemObjListFromControlInput,
mergeControls,
reorderControlsWithDefaultControls,
} from './utils';
import { FiltersChangedBanner } from './filters_changed_banner';
import { FilterGroupContext } from './filter_group_context';
import { NUM_OF_CONTROLS } from './config';
import { TEST_IDS } from './constants';
import { COMMON_OPTIONS_LIST_CONTROL_INPUTS, TEST_IDS, TIMEOUTS } from './constants';
import { URL_PARAM_ARRAY_EXCEPTION_MSG } from './translations';
import { convertToBuildEsQuery } from '../../lib/kuery';
@ -79,6 +81,15 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
const filterChangedSubscription = useRef<Subscription>();
const inputChangedSubscription = useRef<Subscription>();
const initialControlsObj = useMemo(
() =>
initialControls.reduce<Record<string, typeof initialControls[0]>>((prev, current) => {
prev[current.fieldName] = current;
return prev;
}, {}),
[initialControls]
);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const localStoragePageFilterKey = useMemo(
@ -128,7 +139,9 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
const storedControlGroupInput = getStoredControlInput();
if (storedControlGroupInput) {
const panelsFormatted = getFilterItemObjListFromControlInput(storedControlGroupInput);
if (!isEqual(panelsFormatted, param)) {
if (
!isEqualWith(panelsFormatted, param, getFilterControlsComparator('fieldName', 'title'))
) {
setShowFiltersChangedBanner(true);
switchToEditMode();
}
@ -203,8 +216,12 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
);
const handleOutputFilterUpdates = useCallback(
({ filters: newFilters }: ControlGroupOutput) => {
({ filters: newFilters, embeddableLoaded }: ControlGroupOutput) => {
const haveAllEmbeddablesLoaded = Object.values(embeddableLoaded).every((v) =>
Boolean(v ?? true)
);
if (isEqual(currentFiltersRef.current, newFilters)) return;
if (!haveAllEmbeddablesLoaded) return;
if (onFilterChange) onFilterChange(newFilters ?? []);
currentFiltersRef.current = newFilters ?? [];
},
@ -212,7 +229,7 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
);
const debouncedFilterUpdates = useMemo(
() => debounce(handleOutputFilterUpdates, 500),
() => debounce(handleOutputFilterUpdates, TIMEOUTS.FILTER_UPDATES_DEBOUNCE_TIME),
[handleOutputFilterUpdates]
);
@ -253,53 +270,35 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
*
* */
const localInitialControls = cloneDeep(initialControls).filter(
(control) => control.persist === true
);
let resultControls = [] as FilterItemObj[];
let overridingControls = initialUrlParam;
if (!initialUrlParam || initialUrlParam.length === 0) {
// if nothing is found in URL Param.. read from local storage
const storedControlGroupInput = getStoredControlInput();
if (storedControlGroupInput) {
const urlParamsFromLocalStorage: FilterItemObj[] =
getFilterItemObjListFromControlInput(storedControlGroupInput);
overridingControls = urlParamsFromLocalStorage;
}
const controlsFromURL = initialUrlParam ?? [];
let controlsFromLocalStorage: FilterItemObj[] = [];
const storedControlGroupInput = getStoredControlInput();
if (storedControlGroupInput) {
controlsFromLocalStorage = getFilterItemObjListFromControlInput(storedControlGroupInput);
}
let overridingControls = mergeControls({
controlsWithPriority: [controlsFromURL, controlsFromLocalStorage],
defaultControlsObj: initialControlsObj,
});
if (!overridingControls || overridingControls.length === 0) return initialControls;
// if initialUrlParam Exists... replace localInitialControls with what was provided in the Url
if (overridingControls && !urlDataApplied.current) {
if (localInitialControls.length > 0) {
localInitialControls.forEach((persistControl) => {
const doesPersistControlAlreadyExist = overridingControls?.some(
(control) => control.fieldName === persistControl.fieldName
);
overridingControls = overridingControls.map((item) => {
return {
// give default value to params which are coming from the URL
fieldName: item.fieldName,
title: item.title,
selectedOptions: item.selectedOptions ?? [],
existsSelected: item.existsSelected ?? false,
exclude: item.exclude,
};
});
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.exclude,
})),
];
}
return resultControls;
}, [initialUrlParam, initialControls, getStoredControlInput]);
return reorderControlsWithDefaultControls({
controls: overridingControls,
defaultControls: initialControls,
});
}, [initialUrlParam, initialControls, getStoredControlInput, initialControlsObj]);
const fieldFilterPredicate: FieldFilterPredicate = useCallback((f) => f.type !== 'number', []);
@ -325,10 +324,7 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
finalControls.forEach((control, idx) => {
addOptionsListControl(initialInput, {
controlId: String(idx),
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
@ -376,48 +372,29 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
}, [controlGroup, switchToViewMode, getStoredControlInput, hasPendingChanges]);
const upsertPersistableControls = useCallback(async () => {
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;
for (const control of persistableControls) {
const controlExists = currentPanels.some(
(currControl) => control.fieldName === currControl.explicitInput.fieldName
);
if (!controlExists) {
// delete current controls
if (!filterControlsDeleted) {
controlGroup?.updateInput({ panels: {} });
filterControlsDeleted = true;
}
if (!controlGroup) return;
const currentPanels = getFilterItemObjListFromControlInput(controlGroup.getInput());
// add persitable controls
await 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,
});
}
}
const reorderedControls = reorderControlsWithDefaultControls({
controls: currentPanels,
defaultControls: initialControls,
});
for (const panel of orderedPanels) {
if (panel.explicitInput.fieldName)
await controlGroup?.addOptionsListControl({
selectedOptions: [],
fieldName: panel.explicitInput.fieldName,
dataViewId: dataViewId ?? '',
...panel.explicitInput,
});
if (!isEqualWith(reorderedControls, currentPanels, getFilterControlsComparator('fieldName'))) {
// reorder only if fields are in different order
// or not same.
controlGroup?.updateInput({ panels: {} });
for (const control of reorderedControls) {
await controlGroup?.addOptionsListControl({
title: control.title,
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
selectedOptions: control.selectedOptions,
...control,
});
}
}
}, [controlGroup, dataViewId, initialControls]);
@ -428,23 +405,36 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
setShowFiltersChangedBanner(false);
}, [switchToViewMode, upsertPersistableControls]);
const newControlInputTranform: ControlInputTransform = (newInput, controlType) => {
// for any new controls, we want to avoid
// default placeholder
if (controlType === OPTIONS_LIST_CONTROL) {
return {
...newInput,
placeholder: '',
};
}
return newInput;
};
const newControlInputTranform: ControlInputTransform = useCallback(
(newInput, controlType) => {
// for any new controls, we want to avoid
// default placeholder
let result = newInput;
if (controlType === OPTIONS_LIST_CONTROL) {
result = {
...newInput,
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
};
if ((newInput as DataControlInput).fieldName in initialControlsObj) {
result = {
...result,
...initialControlsObj[(newInput as DataControlInput).fieldName],
// title should not be overridden by the initial controls, hence the hardcoding
title: newInput.title ?? result.title,
};
}
}
return result;
},
[initialControlsObj]
);
const addControlsHandler = useCallback(() => {
controlGroup?.openAddDataControlFlyout({
controlInputTransform: newControlInputTranform,
});
}, [controlGroup]);
}, [controlGroup, newControlInputTranform]);
return (
<FilterGroupContext.Provider

View file

@ -25,15 +25,20 @@ export interface FilterContextType {
addControl: (controls: FilterItemObj) => void;
}
export type FilterItemObj = Omit<AddOptionsListControlProps, 'controlId' | 'dataViewId'> &
Pick<OptionsListEmbeddableInput, 'existsSelected' | 'exclude'>;
export type FilterItemObj = Omit<AddOptionsListControlProps, 'controlId' | 'dataViewId'> & {
/*
* Determines the present and order of a control
*
* */
persist?: boolean;
};
export type FilterGroupHandler = ControlGroupContainer;
export type FilterGroupProps = {
dataViewId: string | null;
onFilterChange?: (newFilters: Filter[]) => void;
initialControls: Array<FilterItemObj & { persist?: boolean }>;
initialControls: FilterItemObj[];
spaceId: string;
onInit?: (controlGroupHandler: FilterGroupHandler | undefined) => void;
} & Pick<ControlGroupInput, 'timeRange' | 'filters' | 'query' | 'chainingSystem'>;

View file

@ -6,9 +6,64 @@
*/
import type { ControlGroupInput } from '@kbn/controls-plugin/common';
import { getFilterItemObjListFromControlInput } from './utils';
import {
getFilterItemObjListFromControlInput,
mergeControls,
reorderControlsWithDefaultControls,
getFilterControlsComparator,
} from './utils';
import { initialInputData } from './mocks/data';
import type { FilterItemObj } from './types';
import { isEqualWith } from 'lodash';
const defaultControls: FilterItemObj[] = [
{
fieldName: 'first',
hideActionBar: true,
selectedOptions: ['val1', 'val2'],
},
{
fieldName: 'second',
hideActionBar: true,
selectedOptions: ['val1', 'val2'],
persist: true,
},
];
const firstControlsSet: FilterItemObj[] = [
{
fieldName: 'first',
selectedOptions: ['firstVal'],
},
];
const secondControlsSet: FilterItemObj[] = [
{
fieldName: 'first',
selectedOptions: ['secondVal1', 'secondVal2'],
existsSelected: true,
},
{
fieldName: 'second',
hideActionBar: false,
exclude: true,
},
];
const thirdControlsSet: FilterItemObj[] = [
{
fieldName: 'new',
selectedOptions: [],
},
];
const emptyControlSet: FilterItemObj[] = [];
const defaultControlsObj = defaultControls.reduce((prev, current) => {
prev[current.fieldName] = current;
return prev;
}, {} as Record<string, FilterItemObj>);
describe('utils', () => {
describe('getFilterItemObjListFromControlOutput', () => {
it('should return ordered filterItem where passed in order', () => {
@ -61,4 +116,172 @@ describe('utils', () => {
});
});
});
describe('mergeControls', () => {
it('should return first controls set when it is not empty', () => {
const result = mergeControls({
controlsWithPriority: [firstControlsSet, secondControlsSet],
defaultControlsObj,
});
const expectedResult = [
{
fieldName: 'first',
selectedOptions: ['firstVal'],
hideActionBar: true,
},
];
expect(result).toMatchObject(expectedResult);
});
it('should return second controls set when first one is empty', () => {
const result = mergeControls({
controlsWithPriority: [emptyControlSet, secondControlsSet],
defaultControlsObj,
});
const expectedResult = [
{
fieldName: 'first',
selectedOptions: ['secondVal1', 'secondVal2'],
hideActionBar: true,
existsSelected: true,
},
{
fieldName: 'second',
selectedOptions: ['val1', 'val2'],
hideActionBar: false,
exclude: true,
persist: true,
},
];
expect(result).toMatchObject(expectedResult);
});
it('should return controls as it is when default control for a field does not exist', () => {
const result = mergeControls({
controlsWithPriority: [emptyControlSet, emptyControlSet, thirdControlsSet],
defaultControlsObj,
});
const expectedResult = thirdControlsSet;
expect(result).toMatchObject(expectedResult);
});
it('should return default controls if no priority controls are given', () => {
const result = mergeControls({
controlsWithPriority: [emptyControlSet, emptyControlSet, emptyControlSet],
defaultControlsObj,
});
expect(result).toBeUndefined();
});
});
describe('reorderControls', () => {
it('should add persist controls in order if they are not available in the given controls', () => {
const newControlsSet: FilterItemObj[] = [
{
fieldName: 'new',
},
];
const result = reorderControlsWithDefaultControls({
controls: newControlsSet,
defaultControls,
});
const expectedResult = [
{
fieldName: 'second',
hideActionBar: true,
selectedOptions: ['val1', 'val2'],
persist: true,
},
{
fieldName: 'new',
},
];
expect(result).toMatchObject(expectedResult);
});
it('should change controls order if they are available in the given controls', () => {
const newControlsSet: FilterItemObj[] = [
{
fieldName: 'new',
},
{
fieldName: 'second',
selectedOptions: ['val2'],
hideActionBar: false,
},
{
fieldName: 'first',
selectedOptions: [],
},
];
const expectedResult = [
{
fieldName: 'second',
selectedOptions: ['val2'],
hideActionBar: false,
persist: true,
},
{
fieldName: 'new',
},
{
fieldName: 'first',
selectedOptions: [],
hideActionBar: true,
},
];
const result = reorderControlsWithDefaultControls({
controls: newControlsSet,
defaultControls,
});
expect(result).toMatchObject(expectedResult);
});
});
describe('getFilterControlsComparator', () => {
it('should return true when controls are equal and and list of field is empty', () => {
const comparator = getFilterControlsComparator();
const result = isEqualWith(defaultControls, defaultControls, comparator);
expect(result).toBe(true);
});
it('should return false when arrays of different length', () => {
const comparator = getFilterControlsComparator();
const result = isEqualWith(defaultControls, thirdControlsSet, comparator);
expect(result).toBe(false);
});
it('should return true when given set of fields match ', () => {
const comparator = getFilterControlsComparator('fieldName');
const result = isEqualWith(defaultControls, secondControlsSet, comparator);
expect(result).toBe(true);
});
it("should return false when given set of fields don't match ", () => {
const comparator = getFilterControlsComparator('fieldName', 'selectedOptions');
const result = isEqualWith(defaultControls, secondControlsSet, comparator);
expect(result).toBe(false);
});
it('should return true when comparing empty set of filter controls', () => {
const comparator = getFilterControlsComparator('fieldName', 'selectedOptions');
const result = isEqualWith([], [], comparator);
expect(result).toBe(true);
});
it('should return false when comparing one empty and one non-empty set of filter controls', () => {
const comparator = getFilterControlsComparator('fieldName', 'selectedOptions');
const result = isEqualWith(defaultControls, [], comparator);
expect(result).toBe(false);
});
});
});

View file

@ -11,6 +11,9 @@ import type {
OptionsListEmbeddableInput,
} from '@kbn/controls-plugin/common';
import { isEmpty, isEqual, pick } from 'lodash';
import type { FilterItemObj } from './types';
export const getPanelsInOrderFromControlsInput = (controlInput: ControlGroupInput) => {
const panels = controlInput.panels;
@ -21,7 +24,7 @@ export const getFilterItemObjListFromControlInput = (controlInput: ControlGroupI
const panels = getPanelsInOrderFromControlsInput(controlInput);
return panels.map((panel) => {
const {
explicitInput: { fieldName, selectedOptions, title, existsSelected, exclude },
explicitInput: { fieldName, selectedOptions, title, existsSelected, exclude, hideActionBar },
} = panel as ControlPanelState<OptionsListEmbeddableInput>;
return {
@ -30,6 +33,115 @@ export const getFilterItemObjListFromControlInput = (controlInput: ControlGroupI
title,
existsSelected: existsSelected ?? false,
exclude: exclude ?? false,
hideActionBar: hideActionBar ?? false,
};
});
};
interface MergableControlsArgs {
/*
* Set of controls that need be merged with priority
* Set of controls with lower index take priority over the next one.
*
* Final set of controls is merged with the defaulControls
*
*/
controlsWithPriority: FilterItemObj[][];
defaultControlsObj: Record<string, FilterItemObj>;
}
/*
* mergeControls merges controls based on priority with the default controls
*
* @return undefined if all provided controls are empty
* */
export const mergeControls = ({
controlsWithPriority,
defaultControlsObj,
}: MergableControlsArgs) => {
const highestPriorityControlSet = controlsWithPriority.find((control) => !isEmpty(control));
return highestPriorityControlSet?.map((singleControl) => {
if (singleControl.fieldName in defaultControlsObj) {
return {
...defaultControlsObj[singleControl.fieldName],
...singleControl,
};
}
return singleControl;
});
};
interface ReorderControlsArgs {
/*
* Ordered Controls
*
* */
controls: FilterItemObj[];
/*
* default controls in order
* */
defaultControls: FilterItemObj[];
}
/**
* reorderControlsWithPersistentControls reorders the controls such that controls which
* are persistent in default controls should be upserted in given order
*
* */
export const reorderControlsWithDefaultControls = (args: ReorderControlsArgs) => {
const { controls, defaultControls } = args;
const controlsObject = controls.reduce((prev, current) => {
prev[current.fieldName] = current;
return prev;
}, {} as Record<string, FilterItemObj>);
const defaultControlsObj = defaultControls.reduce((prev, current) => {
prev[current.fieldName] = current;
return prev;
}, {} as Record<string, FilterItemObj>);
const resultDefaultControls: FilterItemObj[] = defaultControls
.filter((defaultControl) => defaultControl.persist)
.map((defaultControl) => {
return {
...defaultControl,
...(controlsObject[defaultControl.fieldName] ?? {}),
};
});
const resultNonPersitantControls = controls
.filter(
// filter out persisting controls since we have already taken
// in account above
(control) => !defaultControlsObj[control.fieldName]?.persist
)
.map((control) => ({
// insert some default properties from default controls
// irrespective of whether they are persistent or not.
...(defaultControlsObj[control.fieldName] ?? {}),
...control,
}));
return [...resultDefaultControls, ...resultNonPersitantControls];
};
/*
* getFilterControlsComparator provides a comparator that can be used with `isEqualWith` to compare
* 2 instances of FilterItemObj
*
* */
export const getFilterControlsComparator =
(...fieldsToCompare: Array<keyof FilterItemObj>) =>
(filterItemObject1: FilterItemObj[], filterItemObject2: FilterItemObj[]) => {
if (filterItemObject1.length !== filterItemObject2.length) return false;
const filterItemObjectWithSelectedKeys1 = filterItemObject1.map((v) => {
return pick(v, fieldsToCompare);
});
const filterItemObjectWithSelectedKeys2 = filterItemObject2.map((v) => {
return pick(v, fieldsToCompare);
});
return isEqual(filterItemObjectWithSelectedKeys1, filterItemObjectWithSelectedKeys2);
};