mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
4af53fe273
commit
6c673240ca
13 changed files with 616 additions and 208 deletions
|
@ -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/,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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"]';
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue