mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Fix] Event Summary columns is not visible in Event Rendered View of Alert Table (#162635)
## Summary Handles. #162471 This PR fixes the stability of visibleColumns in the alert Table. Below videos shows the difference between before and after the change. | Before | After | |--|--| |<video src="06d8616b
-9708-40ed-814f-5899d6158551" /> | <video src="12c86e11
-fccb-4b6f-88cf-1ba2bd96ea52" /> | ## Existing issues. 1. If you noticed in the after video, there remains an existing issue which is the event rendered view is coming without alternating color rows as shown in screenshot below. ( ✅ Fixed in this PR ) 2. Additionally, this bug was also found which also being separately tracked : https://github.com/elastic/kibana/issues/162684 ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
d9f5223e2d
commit
fcfabcdcd1
18 changed files with 604 additions and 95 deletions
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
switchAlertTableToEventRenderedView,
|
||||
switchAlertTableToGridView,
|
||||
waitForAlerts,
|
||||
} from '../../../tasks/alerts';
|
||||
import { navigateFromHeaderTo } from '../../../tasks/security_header';
|
||||
import { FIELDS_BROWSER_BTN } from '../../../screens/rule_details';
|
||||
import {
|
||||
addsFields,
|
||||
closeFieldsBrowser,
|
||||
filterFieldsBrowser,
|
||||
removeField,
|
||||
} from '../../../tasks/fields_browser';
|
||||
import { FIELDS_BROWSER_CONTAINER } from '../../../screens/fields_browser';
|
||||
import { getNewRule } from '../../../objects/rule';
|
||||
import {
|
||||
DATA_GRID_COLUMN_ORDER_BTN,
|
||||
DATA_GRID_FIELDS,
|
||||
DATA_GRID_FULL_SCREEN,
|
||||
GET_DATA_GRID_HEADER,
|
||||
GET_DATA_GRID_HEADER_CELL_ACTION_GROUP,
|
||||
} from '../../../screens/common/data_grid';
|
||||
import { createRule } from '../../../tasks/api_calls/rules';
|
||||
import { cleanKibana } from '../../../tasks/common';
|
||||
import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule';
|
||||
import { login, visit } from '../../../tasks/login';
|
||||
import { ALERTS_URL } from '../../../urls/navigation';
|
||||
import { DATAGRID_HEADER } from '../../../screens/timeline';
|
||||
import { TIMELINES, ALERTS } from '../../../screens/security_header';
|
||||
|
||||
/*
|
||||
*
|
||||
* Alert table is third party component which cannot be easily tested by jest.
|
||||
* This test main checks if Alert Table controls are rendered properly.
|
||||
*
|
||||
* */
|
||||
|
||||
describe(`Alert Table Controls`, () => {
|
||||
before(() => {
|
||||
cleanKibana();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
login();
|
||||
createRule(getNewRule());
|
||||
visit(ALERTS_URL);
|
||||
waitForAlertsToPopulate();
|
||||
});
|
||||
|
||||
it('full screen, column sorting', () => {
|
||||
cy.get(DATA_GRID_FULL_SCREEN)
|
||||
.should('have.attr', 'aria-label', 'Enter fullscreen')
|
||||
.trigger('click');
|
||||
cy.get(DATA_GRID_FULL_SCREEN)
|
||||
.should('have.attr', 'aria-label', 'Exit fullscreen')
|
||||
.trigger('click');
|
||||
cy.get(DATA_GRID_COLUMN_ORDER_BTN).should('be.visible');
|
||||
});
|
||||
|
||||
context('Sorting', () => {
|
||||
it('Date Column', () => {
|
||||
const timestampField = DATA_GRID_FIELDS.TIMESTAMP.fieldName;
|
||||
cy.get(GET_DATA_GRID_HEADER(timestampField)).trigger('click');
|
||||
cy.get(GET_DATA_GRID_HEADER_CELL_ACTION_GROUP(timestampField))
|
||||
.should('be.visible')
|
||||
.should('contain.text', 'Sort Old-New');
|
||||
});
|
||||
|
||||
it('Number column', () => {
|
||||
const riskScoreField = DATA_GRID_FIELDS.RISK_SCORE.fieldName;
|
||||
cy.get(GET_DATA_GRID_HEADER(riskScoreField)).trigger('click');
|
||||
cy.get(GET_DATA_GRID_HEADER_CELL_ACTION_GROUP(riskScoreField))
|
||||
.should('be.visible')
|
||||
.should('contain.text', 'Sort Low-High');
|
||||
});
|
||||
|
||||
it('Text Column', () => {
|
||||
const ruleField = DATA_GRID_FIELDS.RULE.fieldName;
|
||||
cy.get(GET_DATA_GRID_HEADER(ruleField)).trigger('click');
|
||||
cy.get(GET_DATA_GRID_HEADER_CELL_ACTION_GROUP(ruleField))
|
||||
.should('be.visible')
|
||||
.should('contain.text', 'Sort A-Z');
|
||||
});
|
||||
});
|
||||
|
||||
context('Columns Configuration', () => {
|
||||
it('should retain column configuration when a column is removed when coming back to alert page', () => {
|
||||
const fieldName = 'kibana.alert.severity';
|
||||
cy.get(FIELDS_BROWSER_BTN).click();
|
||||
cy.get(FIELDS_BROWSER_CONTAINER).should('be.visible');
|
||||
|
||||
filterFieldsBrowser(fieldName);
|
||||
removeField(fieldName);
|
||||
closeFieldsBrowser();
|
||||
cy.get(DATAGRID_HEADER(fieldName)).should('not.exist');
|
||||
|
||||
navigateFromHeaderTo(TIMELINES);
|
||||
navigateFromHeaderTo(ALERTS);
|
||||
waitForAlerts();
|
||||
cy.get(DATAGRID_HEADER('_id')).should('not.exist');
|
||||
});
|
||||
it('should retain column configuration when a column is added when coming back to alert page', () => {
|
||||
cy.get(FIELDS_BROWSER_BTN).click();
|
||||
cy.get(FIELDS_BROWSER_CONTAINER).should('be.visible');
|
||||
|
||||
addsFields(['_id']);
|
||||
closeFieldsBrowser();
|
||||
cy.get(DATAGRID_HEADER('_id')).should('be.visible');
|
||||
|
||||
navigateFromHeaderTo(TIMELINES);
|
||||
navigateFromHeaderTo(ALERTS);
|
||||
waitForAlerts();
|
||||
cy.get(DATAGRID_HEADER('_id')).should('be.visible');
|
||||
});
|
||||
it('should retain columns configuration when switching between eventrenderedView and gridView', () => {
|
||||
const fieldName = '_id';
|
||||
cy.get(FIELDS_BROWSER_BTN).click();
|
||||
cy.get(FIELDS_BROWSER_CONTAINER).should('be.visible');
|
||||
|
||||
addsFields([fieldName]);
|
||||
closeFieldsBrowser();
|
||||
cy.get(DATAGRID_HEADER(fieldName)).should('be.visible');
|
||||
|
||||
switchAlertTableToEventRenderedView();
|
||||
cy.get(DATAGRID_HEADER(fieldName)).should('not.exist');
|
||||
|
||||
switchAlertTableToGridView();
|
||||
cy.get(DATAGRID_HEADER(fieldName)).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FIELDS_BROWSER_BTN } from '../../../screens/rule_details';
|
||||
import { getNewRule } from '../../../objects/rule';
|
||||
import {
|
||||
EVENT_SUMMARY_ALERT_RENDERER_CONTENT,
|
||||
EVENT_SUMMARY_COLUMN,
|
||||
ALERT_RENDERER_HOST_NAME,
|
||||
SHOW_TOP_N_HEADER,
|
||||
} from '../../../screens/alerts';
|
||||
import {
|
||||
DATA_GRID_COLUMN_ORDER_BTN,
|
||||
DATA_GRID_FIELD_SORT_BTN,
|
||||
} from '../../../screens/common/data_grid';
|
||||
import { HOVER_ACTIONS } from '../../../screens/timeline';
|
||||
import {
|
||||
showHoverActionsEventRenderedView,
|
||||
switchAlertTableToEventRenderedView,
|
||||
waitForAlerts,
|
||||
} from '../../../tasks/alerts';
|
||||
import { createRule } from '../../../tasks/api_calls/rules';
|
||||
import { cleanKibana } from '../../../tasks/common';
|
||||
import { login, visit } from '../../../tasks/login';
|
||||
import { ALERTS_URL } from '../../../urls/navigation';
|
||||
import {
|
||||
TOP_N_ALERT_HISTOGRAM,
|
||||
TOP_N_CONTAINER_CLOSE_BTN,
|
||||
XY_CHART,
|
||||
} from '../../../screens/shared';
|
||||
|
||||
describe(`Event Rendered View`, () => {
|
||||
before(() => {
|
||||
cleanKibana();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
login();
|
||||
createRule(getNewRule());
|
||||
visit(ALERTS_URL);
|
||||
waitForAlerts();
|
||||
switchAlertTableToEventRenderedView();
|
||||
waitForAlerts();
|
||||
});
|
||||
|
||||
it('Event Summary Column', () => {
|
||||
cy.get(EVENT_SUMMARY_COLUMN).should('be.visible');
|
||||
cy.get(EVENT_SUMMARY_ALERT_RENDERER_CONTENT).should('be.visible');
|
||||
});
|
||||
|
||||
it('Hover Action TopN in event summary column', () => {
|
||||
showHoverActionsEventRenderedView(ALERT_RENDERER_HOST_NAME);
|
||||
cy.get(HOVER_ACTIONS.SHOW_TOP).trigger('click');
|
||||
cy.get(TOP_N_ALERT_HISTOGRAM).should('be.visible');
|
||||
cy.get(SHOW_TOP_N_HEADER).first().should('have.text', 'Top host.name');
|
||||
cy.get(XY_CHART).should('be.visible');
|
||||
cy.get(TOP_N_CONTAINER_CLOSE_BTN).trigger('click');
|
||||
cy.get(XY_CHART).should('not.be.visible');
|
||||
});
|
||||
|
||||
/*
|
||||
*
|
||||
* Alert table is third party component which cannot be easily tested by jest.
|
||||
* This test main checks if Alert Table controls are rendered properly.
|
||||
*
|
||||
* */
|
||||
it('Field Browser is not visible', () => {
|
||||
cy.get(FIELDS_BROWSER_BTN).should('not.exist');
|
||||
});
|
||||
|
||||
it('Sorting control is not visible', () => {
|
||||
cy.get(DATA_GRID_FIELD_SORT_BTN).should('not.be.visible');
|
||||
});
|
||||
|
||||
it('Column Order button is not visible', () => {
|
||||
cy.get(DATA_GRID_COLUMN_ORDER_BTN).should('not.exist');
|
||||
});
|
||||
});
|
|
@ -202,3 +202,22 @@ export const MIXED_ALERT_TAG = '[data-test-subj="mixed-alert-tag"]';
|
|||
export const UNSELECTED_ALERT_TAG = '[data-test-subj="unselected-alert-tag"]';
|
||||
|
||||
export const ALERTS_TABLE_ROW_LOADER = '[data-test-subj="row-loader"]';
|
||||
|
||||
export const ALERT_TABLE_SUMMARY_VIEW_SELECTABLE = '[data-test-subj="summary-view-selector"]';
|
||||
|
||||
export const ALERT_TABLE_GRID_VIEW_OPTION = '[data-test-subj="gridView"]';
|
||||
|
||||
export const EVENT_SUMMARY_COLUMN = '[data-gridcell-column-id="eventSummary"]';
|
||||
|
||||
export const EVENT_SUMMARY_ALERT_RENDERER_CONTENT = '[data-test-subj="alertRenderer"]';
|
||||
|
||||
export const ALERT_TABLE_EVENT_RENDERED_VIEW_OPTION = '[data-test-subj="eventRenderedView"]';
|
||||
|
||||
export const ALERT_TABLE_ADDITIONAL_CONTROLS = '[data-test-subj="additionalFilters-popover"]';
|
||||
|
||||
export const ALERT_RENDERER_CONTENT = '[data-test-subj="alertRenderer"]';
|
||||
|
||||
export const ALERT_RENDERER_HOST_NAME =
|
||||
'[data-test-subj="alertFieldBadge"] [data-test-subj="render-content-host.name"]';
|
||||
|
||||
export const HOVER_ACTIONS_CONTAINER = getDataTestSubjectSelector('hover-actions-container');
|
||||
|
|
|
@ -8,3 +8,41 @@
|
|||
export const GET_DATA_GRID_HEADER = (fieldName: string) => {
|
||||
return `[data-test-subj="dataGridHeaderCell-${fieldName}"]`;
|
||||
};
|
||||
|
||||
export const DATA_GRID_FIELDS = {
|
||||
TIMESTAMP: {
|
||||
fieldName: '@timestamp',
|
||||
label: '@timestamp',
|
||||
},
|
||||
ID: {
|
||||
fieldName: '_id',
|
||||
label: '_id',
|
||||
},
|
||||
RISK_SCORE: {
|
||||
fieldName: 'kibana.alert.risk_score',
|
||||
label: 'Risk Score',
|
||||
},
|
||||
|
||||
RULE: {
|
||||
fieldName: 'kibana.alert.rule.name',
|
||||
label: 'Rule',
|
||||
},
|
||||
};
|
||||
|
||||
export const GET_DATA_GRID_HEADER_CELL_ACTION_GROUP = (fieldName: string) => {
|
||||
return `[data-test-subj="dataGridHeaderCellActionGroup-${fieldName}"]`;
|
||||
};
|
||||
|
||||
export const DATA_GRID_FULL_SCREEN =
|
||||
'[data-test-subj="alertsTable"] [data-test-subj="dataGridFullScreenButton"]';
|
||||
|
||||
export const DATA_GRID_FIELD_SORT_BTN = '[data-test-subj="dataGridColumnSortingButton"]';
|
||||
|
||||
export const DATA_GRID_COLUMN_ORDER_BTN = '[data-test-subj="dataGridColumnSelectorButton"]';
|
||||
|
||||
export const DATA_GRID_COLUMNS = '.euiDataGridHeaderCell__content';
|
||||
|
||||
export const COLUMN_ORDER_POPUP = {
|
||||
TIMESTAMP: '[data-test-subj="dataGridColumnSelectorColumnItem-@timestamp"]',
|
||||
REASON: '[data-test-subj="dataGridColumnSelectorColumnItem-kibana.alert.reason"]',
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export const CLOSE_BTN = '[data-test-subj="close"]';
|
||||
export const FIELD_BROWSER_CLOSE_BTN = '[data-test-subj="close"]';
|
||||
|
||||
export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]';
|
||||
|
||||
|
@ -34,6 +34,9 @@ export const FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER =
|
|||
|
||||
export const FIELDS_BROWSER_MESSAGE_CHECKBOX = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-message-checkbox"]`;
|
||||
|
||||
export const GET_FIELD_CHECKBOX = (fieldName: string) =>
|
||||
`${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-${fieldName}-checkbox"]`;
|
||||
|
||||
export const FIELDS_BROWSER_MESSAGE_HEADER =
|
||||
'[data-test-subj="timeline"] [data-test-subj="header-text-message"]';
|
||||
|
||||
|
|
|
@ -8,3 +8,13 @@
|
|||
export const TOAST_ERROR = '.euiToast--danger';
|
||||
|
||||
export const SELECT_ALL_CHECKBOX = '[data-test-subj="checkboxSelectAll"]';
|
||||
|
||||
export const TOP_N_ALERT_HISTOGRAM =
|
||||
'[data-test-subj="topN-container"] [data-test-subj="alerts-histogram-panel"]';
|
||||
|
||||
export const TOP_N_CONTAINER = '[data-test-subj="topN-container"]';
|
||||
|
||||
export const TOP_N_CONTAINER_CLOSE_BTN =
|
||||
'[data-test-subj="topN-container"] [data-test-subj="close"]';
|
||||
|
||||
export const XY_CHART = '[data-test-subj="xyVisChart"]';
|
||||
|
|
|
@ -333,6 +333,7 @@ export const HOVER_ACTIONS = {
|
|||
FILTER_FOR: '[data-test-subj="hover-actions-filter-for"]',
|
||||
FILTER_OUT: '[data-test-subj="hovhover-actions-filter-out"]',
|
||||
COPY: '[data-test-subj="hover-actions-copy-button"]',
|
||||
SHOW_TOP: '[data-test-subj=show-top-field]',
|
||||
};
|
||||
|
||||
export const GET_TIMELINE_HEADER = (fieldName: string) => {
|
||||
|
|
|
@ -49,6 +49,11 @@ import {
|
|||
ALERT_TAGGING_CONTEXT_MENU_ITEM,
|
||||
ALERT_TAGGING_CONTEXT_MENU,
|
||||
ALERT_TAGGING_UPDATE_BUTTON,
|
||||
ALERTS_HISTOGRAM_PANEL_LOADER,
|
||||
ALERT_TABLE_SUMMARY_VIEW_SELECTABLE,
|
||||
ALERT_TABLE_EVENT_RENDERED_VIEW_OPTION,
|
||||
HOVER_ACTIONS_CONTAINER,
|
||||
ALERT_TABLE_GRID_VIEW_OPTION,
|
||||
} from '../screens/alerts';
|
||||
import { LOADING_INDICATOR, REFRESH_BUTTON } from '../screens/security_header';
|
||||
import { TIMELINE_COLUMN_SPINNER } from '../screens/timeline';
|
||||
|
@ -479,3 +484,23 @@ export const clickAlertTag = (tag: string) => {
|
|||
export const updateAlertTags = () => {
|
||||
cy.get(ALERT_TAGGING_UPDATE_BUTTON).click();
|
||||
};
|
||||
|
||||
export const showHoverActionsEventRenderedView = (fieldSelector: string) => {
|
||||
cy.get(fieldSelector).first().trigger('mouseover');
|
||||
cy.get(HOVER_ACTIONS_CONTAINER).should('be.visible');
|
||||
};
|
||||
|
||||
export const waitForTopNHistogramToLoad = () => {
|
||||
cy.get(ALERTS_HISTOGRAM_PANEL_LOADER).should('exist');
|
||||
cy.get(ALERTS_HISTOGRAM_PANEL_LOADER).should('not.exist');
|
||||
};
|
||||
|
||||
export const switchAlertTableToEventRenderedView = () => {
|
||||
cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click');
|
||||
cy.get(ALERT_TABLE_EVENT_RENDERED_VIEW_OPTION).should('be.visible').trigger('click');
|
||||
};
|
||||
|
||||
export const switchAlertTableToGridView = () => {
|
||||
cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click');
|
||||
cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click');
|
||||
};
|
||||
|
|
|
@ -12,13 +12,14 @@ import {
|
|||
FIELDS_BROWSER_MESSAGE_CHECKBOX,
|
||||
FIELDS_BROWSER_RESET_FIELDS,
|
||||
FIELDS_BROWSER_CHECKBOX,
|
||||
CLOSE_BTN,
|
||||
FIELD_BROWSER_CLOSE_BTN,
|
||||
FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON,
|
||||
FIELDS_BROWSER_CATEGORY_FILTER_OPTION,
|
||||
FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH,
|
||||
FIELDS_BROWSER_VIEW_ALL,
|
||||
FIELDS_BROWSER_VIEW_BUTTON,
|
||||
FIELDS_BROWSER_VIEW_SELECTED,
|
||||
GET_FIELD_CHECKBOX,
|
||||
} from '../screens/fields_browser';
|
||||
|
||||
export const addsFields = (fields: string[]) => {
|
||||
|
@ -50,7 +51,7 @@ export const clearFieldsBrowser = () => {
|
|||
};
|
||||
|
||||
export const closeFieldsBrowser = () => {
|
||||
cy.get(CLOSE_BTN).click({ force: true });
|
||||
cy.get(FIELD_BROWSER_CLOSE_BTN).click({ force: true });
|
||||
cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.exist');
|
||||
};
|
||||
|
||||
|
@ -83,6 +84,10 @@ export const removesMessageField = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const removeField = (fieldName: string) => {
|
||||
cy.get(GET_FIELD_CHECKBOX(fieldName)).uncheck({ force: true });
|
||||
};
|
||||
|
||||
export const resetFields = () => {
|
||||
cy.get(FIELDS_BROWSER_RESET_FIELDS).click({ force: true });
|
||||
};
|
||||
|
|
|
@ -84,7 +84,7 @@ export const RightTopMenu = ({
|
|||
</UpdatedFlexItem>
|
||||
{tGridEventRenderedViewEnabled &&
|
||||
[TableId.alertsOnRuleDetailsPage, TableId.alertsOnAlertsPage].includes(tableId) && (
|
||||
<UpdatedFlexItem grow={false} $show={!loading}>
|
||||
<UpdatedFlexItem grow={false} $show={!loading} data-test-subj="summary-view-selector">
|
||||
<SummaryViewSelector viewSelected={tableView} onViewChange={onViewChange} />
|
||||
</UpdatedFlexItem>
|
||||
)}
|
||||
|
|
|
@ -79,6 +79,7 @@ const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryVie
|
|||
() => [
|
||||
{
|
||||
label: gridView,
|
||||
'data-test-subj': 'gridView',
|
||||
key: 'gridView',
|
||||
checked: (viewSelected === 'gridView' ? 'on' : undefined) as EuiSelectableOption['checked'],
|
||||
meta: [
|
||||
|
@ -95,6 +96,7 @@ const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryVie
|
|||
},
|
||||
{
|
||||
label: eventRenderedView,
|
||||
'data-test-subj': 'eventRenderedView',
|
||||
key: 'eventRenderedView',
|
||||
checked: (viewSelected === 'eventRenderedView'
|
||||
? 'on'
|
||||
|
|
|
@ -272,6 +272,7 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
})}
|
||||
>
|
||||
<Container
|
||||
data-test-subj="hover-actions-container"
|
||||
onKeyDown={onKeyDown}
|
||||
$showTopN={showTopN}
|
||||
$showOwnFocus={showOwnFocus}
|
||||
|
|
|
@ -113,13 +113,6 @@ const TopNComponent: React.FC<Props> = ({
|
|||
|
||||
return (
|
||||
<TopNContainer data-test-subj="topN-container">
|
||||
<CloseButton
|
||||
aria-label={i18n.CLOSE}
|
||||
data-test-subj="close"
|
||||
iconType="cross"
|
||||
onClick={toggleTopN}
|
||||
/>
|
||||
|
||||
<TopNContent>
|
||||
{view === 'raw' || view === 'all' ? (
|
||||
<EventsByDataset
|
||||
|
@ -159,6 +152,13 @@ const TopNComponent: React.FC<Props> = ({
|
|||
/>
|
||||
)}
|
||||
</TopNContent>
|
||||
|
||||
<CloseButton
|
||||
aria-label={i18n.CLOSE}
|
||||
data-test-subj="close"
|
||||
iconType="cross"
|
||||
onClick={toggleTopN}
|
||||
/>
|
||||
</TopNContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,7 +10,6 @@ import { EuiFlexGroup } from '@elastic/eui';
|
|||
import type { Filter } from '@kbn/es-query';
|
||||
import type { FC } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { AlertsTableStateProps } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_table/alerts_table_state';
|
||||
import type { Alert } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { ALERT_BUILDING_BLOCK_TYPE } from '@kbn/rule-data-utils';
|
||||
|
@ -37,7 +36,6 @@ import { inputsSelectors } from '../../../common/store';
|
|||
import { combineQueries } from '../../../common/lib/kuery';
|
||||
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
|
||||
import { StatefulEventContext } from '../../../common/components/events_viewer/stateful_event_context';
|
||||
import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
|
@ -48,14 +46,13 @@ import { eventsViewerSelector } from '../../../common/components/events_viewer/s
|
|||
import type { State } from '../../../common/store';
|
||||
import * as i18n from './translations';
|
||||
import { eventRenderedViewColumns } from '../../configurations/security_solution_detections/columns';
|
||||
import { getAlertsDefaultModel } from './default_config';
|
||||
|
||||
const { updateIsLoading, updateTotalCount } = dataTableActions;
|
||||
|
||||
// Highlight rows with building block alerts
|
||||
const shouldHighlightRow = (alert: Alert) => !!alert[ALERT_BUILDING_BLOCK_TYPE];
|
||||
|
||||
const storage = new Storage(localStorage);
|
||||
|
||||
interface GridContainerProps {
|
||||
hideLastPage: boolean;
|
||||
}
|
||||
|
@ -154,7 +151,8 @@ export const AlertsTableComponent: FC<DetectionEngineAlertTableProps> = ({
|
|||
graphEventId, // If truthy, the graph viewer (Resolver) is showing
|
||||
sessionViewConfig,
|
||||
viewMode: tableView = eventsDefaultModel.viewMode,
|
||||
} = eventsDefaultModel,
|
||||
columns,
|
||||
} = getAlertsDefaultModel(license),
|
||||
} = useShallowEqualSelector((state: State) => eventsViewerSelector(state, tableId));
|
||||
|
||||
const combinedQuery = useMemo(() => {
|
||||
|
@ -210,9 +208,10 @@ export const AlertsTableComponent: FC<DetectionEngineAlertTableProps> = ({
|
|||
return undefined;
|
||||
}, [isEventRenderedView]);
|
||||
|
||||
const dataTableStorage = getDataTablesInStorageByIds(storage, [TableId.alertsOnAlertsPage]);
|
||||
const columnsFormStorage = dataTableStorage?.[TableId.alertsOnAlertsPage]?.columns ?? [];
|
||||
const alertColumns = columnsFormStorage.length ? columnsFormStorage : getColumns(license);
|
||||
const alertColumns = useMemo(
|
||||
() => (columns.length ? columns : getColumns(license)),
|
||||
[columns, license]
|
||||
);
|
||||
|
||||
const finalBrowserFields = useMemo(
|
||||
() => (isEventRenderedView ? {} : browserFields),
|
||||
|
|
|
@ -308,17 +308,32 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
|
|||
if (shouldHighlightRowCheck) {
|
||||
mappedRowClasses = alerts.reduce<NonNullable<EuiDataGridStyle['rowClasses']>>(
|
||||
(rowClasses, alert, index) => {
|
||||
if (props.gridStyle?.stripes && index % 2 !== 0) {
|
||||
// manually add stripes if props.gridStyle.stripes is true because presence of rowClasses
|
||||
// overrides the props.gridStyle.stripes option. And rowClasses will always be there.
|
||||
// Adding strips only on even rows. It will be replace by alertsTableHighlightedRow if
|
||||
// shouldHighlightRow is correct
|
||||
rowClasses[index + pagination.pageIndex * pagination.pageSize] =
|
||||
'euiDataGridRow--striped';
|
||||
}
|
||||
if (shouldHighlightRowCheck(alert)) {
|
||||
rowClasses[index + pagination.pageIndex * pagination.pageSize] =
|
||||
'alertsTableHighlightedRow';
|
||||
}
|
||||
|
||||
return rowClasses;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
return mappedRowClasses;
|
||||
}, [props.shouldHighlightRow, alerts, pagination.pageIndex, pagination.pageSize]);
|
||||
}, [
|
||||
props.shouldHighlightRow,
|
||||
alerts,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
props.gridStyle,
|
||||
]);
|
||||
|
||||
const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]);
|
||||
|
||||
|
|
|
@ -178,7 +178,7 @@ const AlertsTableStateWithQueryProvider = ({
|
|||
: EmptyConfiguration;
|
||||
|
||||
const storage = useRef(new Storage(window.localStorage));
|
||||
const localAlertsTableConfig = storage.current.get(id) as Partial<AlertsTableStorage>;
|
||||
const localStorageAlertsTableConfig = storage.current.get(id) as Partial<AlertsTableStorage>;
|
||||
const persistentControls = alertsTableConfiguration?.usePersistentControls?.();
|
||||
const showInspectButton = alertsTableConfiguration?.showInspectButton ?? false;
|
||||
|
||||
|
@ -186,25 +186,25 @@ const AlertsTableStateWithQueryProvider = ({
|
|||
propColumns && !isEmpty(propColumns) ? propColumns : alertsTableConfiguration?.columns ?? [];
|
||||
|
||||
const columnsLocal =
|
||||
localAlertsTableConfig &&
|
||||
localAlertsTableConfig.columns &&
|
||||
!isEmpty(localAlertsTableConfig?.columns)
|
||||
? localAlertsTableConfig?.columns ?? []
|
||||
localStorageAlertsTableConfig &&
|
||||
localStorageAlertsTableConfig.columns &&
|
||||
!isEmpty(localStorageAlertsTableConfig?.columns)
|
||||
? localStorageAlertsTableConfig?.columns
|
||||
: columnConfigByClient;
|
||||
|
||||
const getStorageConfig = () => ({
|
||||
columns: columnsLocal,
|
||||
sort:
|
||||
localAlertsTableConfig &&
|
||||
localAlertsTableConfig.sort &&
|
||||
!isEmpty(localAlertsTableConfig?.sort)
|
||||
? localAlertsTableConfig?.sort ?? []
|
||||
localStorageAlertsTableConfig &&
|
||||
localStorageAlertsTableConfig.sort &&
|
||||
!isEmpty(localStorageAlertsTableConfig?.sort)
|
||||
? localStorageAlertsTableConfig?.sort
|
||||
: alertsTableConfiguration?.sort ?? [],
|
||||
visibleColumns:
|
||||
localAlertsTableConfig &&
|
||||
localAlertsTableConfig.visibleColumns &&
|
||||
!isEmpty(localAlertsTableConfig?.visibleColumns)
|
||||
? localAlertsTableConfig?.visibleColumns ?? []
|
||||
localStorageAlertsTableConfig &&
|
||||
localStorageAlertsTableConfig.visibleColumns &&
|
||||
!isEmpty(localStorageAlertsTableConfig?.visibleColumns)
|
||||
? localStorageAlertsTableConfig?.visibleColumns
|
||||
: columnsLocal.map((c) => c.id),
|
||||
});
|
||||
const storageAlertsTable = useRef<AlertsTableStorage>(getStorageConfig());
|
||||
|
|
|
@ -13,6 +13,7 @@ import { act, renderHook } from '@testing-library/react-hooks';
|
|||
import { useColumns, UseColumnsArgs, UseColumnsResp } from './use_columns';
|
||||
import { useFetchBrowserFieldCapabilities } from '../use_fetch_browser_fields_capabilities';
|
||||
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
|
||||
import { AlertsTableStorage } from '../../alerts_table_state';
|
||||
|
||||
jest.mock('../../../../../common/lib/kibana');
|
||||
jest.mock('../use_fetch_browser_fields_capabilities');
|
||||
|
@ -29,12 +30,15 @@ describe('useColumn', () => {
|
|||
const id = 'useColumnTest';
|
||||
const featureIds: AlertConsumers[] = [AlertConsumers.LOGS, AlertConsumers.APM];
|
||||
let storage = { current: new Storage(mockStorage) };
|
||||
const storageAlertsTable = {
|
||||
current: {
|
||||
columns: [],
|
||||
visibleColumns: [],
|
||||
sort: [],
|
||||
},
|
||||
|
||||
const getStorageAlertsTableByDefaultColumns = (defaultColumns: EuiDataGridColumn[]) => {
|
||||
return {
|
||||
current: {
|
||||
columns: defaultColumns,
|
||||
visibleColumns: defaultColumns.map((col) => col.id),
|
||||
sort: [],
|
||||
} as AlertsTableStorage,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultColumns: EuiDataGridColumn[] = [
|
||||
|
@ -98,43 +102,18 @@ describe('useColumn', () => {
|
|||
storage = { current: new Storage(mockStorage) };
|
||||
});
|
||||
|
||||
test('hide all columns with onChangeVisibleColumns', async () => {
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({ defaultColumns, featureIds, id, storageAlertsTable, storage })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns([]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleColumns).toEqual([]);
|
||||
expect(result.current.columns).toEqual(defaultColumns);
|
||||
});
|
||||
|
||||
test('show all columns with onChangeVisibleColumns', async () => {
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({ defaultColumns, featureIds, id, storageAlertsTable, storage })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns([]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns(defaultColumns.map((dc) => dc.id));
|
||||
});
|
||||
expect(result.current.visibleColumns).toEqual([
|
||||
'event.action',
|
||||
'@timestamp',
|
||||
'kibana.alert.duration.us',
|
||||
'kibana.alert.reason',
|
||||
]);
|
||||
expect(result.current.columns).toEqual(defaultColumns);
|
||||
});
|
||||
|
||||
test('onColumnResize', async () => {
|
||||
// storageTable will always be in sync with defualtColumns.
|
||||
// it is an invariant. If that is the case, that can be considered an issue
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({ defaultColumns, featureIds, id, storageAlertsTable, storage })
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
|
@ -152,4 +131,196 @@ describe('useColumn', () => {
|
|||
schema: 'datetime',
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleColumns', () => {
|
||||
test('hide all columns with onChangeVisibleColumns', async () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns([]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleColumns).toEqual([]);
|
||||
expect(result.current.columns).toEqual(defaultColumns);
|
||||
});
|
||||
|
||||
test('show all columns with onChangeVisibleColumns', async () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns([]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.onChangeVisibleColumns(defaultColumns.map((dc) => dc.id));
|
||||
});
|
||||
expect(result.current.visibleColumns).toEqual([
|
||||
'event.action',
|
||||
'@timestamp',
|
||||
'kibana.alert.duration.us',
|
||||
'kibana.alert.reason',
|
||||
]);
|
||||
expect(result.current.columns).toEqual(defaultColumns);
|
||||
});
|
||||
|
||||
test('should populate visiblecolumns correctly', async () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.visibleColumns).toMatchObject(defaultColumns.map((col) => col.id));
|
||||
});
|
||||
|
||||
test('should change visiblecolumns if provided defaultColumns change', async () => {
|
||||
let localDefaultColumns = [...defaultColumns];
|
||||
let localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(localDefaultColumns);
|
||||
const { result, rerender } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns: localDefaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.visibleColumns).toMatchObject(defaultColumns.map((col) => col.id));
|
||||
|
||||
/*
|
||||
*
|
||||
* TODO : it looks like when defaultColumn is changed, the storageAlertTable
|
||||
* is also changed automatically outside this hook i.e. storageAlertsTable = localStorageColumns ?? defaultColumns
|
||||
*
|
||||
* ideally everything related to columns should be pulled in this particular hook. So that it is easy
|
||||
* to measure the effects based on single set of props. Just by looking at this hook
|
||||
* it is impossible to know that defaultColumn and storageAlertsTable both are always in sync and should
|
||||
* be kept in sync manually when running tests.
|
||||
*
|
||||
* */
|
||||
localDefaultColumns = localDefaultColumns.slice(0, 3);
|
||||
localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(localDefaultColumns);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.visibleColumns).toMatchObject(localDefaultColumns.map((col) => col.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe('columns', () => {
|
||||
test('should changes the column list when defaultColumns has been updated', async () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result, waitFor } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.columns).toMatchObject(defaultColumns));
|
||||
});
|
||||
});
|
||||
|
||||
describe('onToggleColumns', () => {
|
||||
test('should update the list of columns when on Toggle Columns is called', () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onToggleColumn(defaultColumns[0].id);
|
||||
});
|
||||
|
||||
expect(result.current.columns).toMatchObject(defaultColumns.slice(1));
|
||||
});
|
||||
|
||||
test('should update the list of visible columns when onToggleColumn is called', async () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
// remove particular column
|
||||
act(() => {
|
||||
result.current.onToggleColumn(defaultColumns[0].id);
|
||||
});
|
||||
|
||||
expect(result.current.columns).toMatchObject(defaultColumns.slice(1));
|
||||
|
||||
// make it visible again
|
||||
act(() => {
|
||||
result.current.onToggleColumn(defaultColumns[0].id);
|
||||
});
|
||||
|
||||
expect(result.current.columns).toMatchObject(defaultColumns);
|
||||
});
|
||||
|
||||
test('should update the column details in the storage when onToggleColumn is called', () => {
|
||||
const localStorageAlertsTable = getStorageAlertsTableByDefaultColumns(defaultColumns);
|
||||
const { result } = renderHook<UseColumnsArgs, UseColumnsResp>(() =>
|
||||
useColumns({
|
||||
defaultColumns,
|
||||
featureIds,
|
||||
id,
|
||||
storageAlertsTable: localStorageAlertsTable,
|
||||
storage,
|
||||
})
|
||||
);
|
||||
|
||||
// remove particular column
|
||||
act(() => {
|
||||
setItemStorageMock.mockClear();
|
||||
result.current.onToggleColumn(defaultColumns[0].id);
|
||||
});
|
||||
|
||||
expect(setItemStorageMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
id,
|
||||
JSON.stringify({
|
||||
columns: defaultColumns.slice(1),
|
||||
visibleColumns: defaultColumns.slice(1).map((col) => col.id),
|
||||
sort: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
|||
import { BrowserField, BrowserFields } from '@kbn/rule-registry-plugin/common';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertConsumers } from '@kbn/rule-data-utils';
|
||||
import { isEmpty, isEqual } from 'lodash';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { AlertsTableStorage } from '../../alerts_table_state';
|
||||
import { toggleColumn } from './toggle_column';
|
||||
import { useFetchBrowserFieldCapabilities } from '../use_fetch_browser_fields_capabilities';
|
||||
|
@ -182,21 +182,35 @@ export const useColumns = ({
|
|||
|
||||
const defaultColumnsRef = useRef<typeof defaultColumns>(defaultColumns);
|
||||
|
||||
const didDefaultColumnChange = useMemo(
|
||||
() => !isEqual(defaultColumns, defaultColumnsRef.current),
|
||||
[defaultColumns]
|
||||
const didDefaultColumnChange = defaultColumns !== defaultColumnsRef.current;
|
||||
|
||||
const setColumnsByColumnIds = useCallback(
|
||||
(columnIds: string[]) => {
|
||||
setVisibleColumns(columnIds);
|
||||
persist({
|
||||
id,
|
||||
storage,
|
||||
storageAlertsTable,
|
||||
columns,
|
||||
visibleColumns: columnIds,
|
||||
});
|
||||
},
|
||||
[columns, id, storage, storageAlertsTable]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// if defaultColumns have changed,
|
||||
// get the latest columns provided by client and
|
||||
if (didDefaultColumnChange) {
|
||||
if (didDefaultColumnChange && defaultColumnsRef.current) {
|
||||
defaultColumnsRef.current = defaultColumns;
|
||||
setColumnsPopulated(false);
|
||||
// storageAlertTable already account for the changes in defaultColumns
|
||||
// Technically storageAlertsTable = localStorageData ?? defaultColumns
|
||||
setColumns(storageAlertsTable.current.columns);
|
||||
setVisibleColumns(storageAlertsTable.current.visibleColumns ?? visibleColumns);
|
||||
return;
|
||||
}
|
||||
}, [didDefaultColumnChange, storageAlertsTable, defaultColumns]);
|
||||
}, [didDefaultColumnChange, storageAlertsTable, defaultColumns, visibleColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(browserFields) || isColumnsPopulated) return;
|
||||
|
@ -221,20 +235,6 @@ export const useColumns = ({
|
|||
[id, storage, storageAlertsTable]
|
||||
);
|
||||
|
||||
const setColumnsByColumnIds = useCallback(
|
||||
(columnIds: string[]) => {
|
||||
setVisibleColumns(columnIds);
|
||||
persist({
|
||||
id,
|
||||
storage,
|
||||
storageAlertsTable,
|
||||
columns,
|
||||
visibleColumns: columnIds,
|
||||
});
|
||||
},
|
||||
[columns, id, storage, storageAlertsTable]
|
||||
);
|
||||
|
||||
const onToggleColumn = useCallback(
|
||||
(columnId: string): void => {
|
||||
const column = euiColumnFactory(columnId, browserFields, defaultColumns);
|
||||
|
@ -279,7 +279,7 @@ export const useColumns = ({
|
|||
* In some case such security, we need some special fields such as threat.enrichments which are
|
||||
* not fetched when passing only EMPTY_FIELDS. Hence, we will fetch all the fields that user has added to the table.
|
||||
*
|
||||
* Additionaly, system such as o11y needs fields which are not even added in the table such as rule_type_id and hence we
|
||||
* Additionally, system such as o11y needs fields which are not even added in the table such as rule_type_id and hence we
|
||||
* additionly pass EMPTY_FIELDS so that it brings all fields apart from special fields
|
||||
*
|
||||
* */
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue