From 30ddff5953258679caa256ff1ce0f6fe08adb2b6 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 3 Aug 2021 16:41:44 -0600 Subject: [PATCH] [RAC] [TGrid] Implements sorting in the `TGrid` (#107495) ## Summary This PR implements sorting in the `TGrid`, per the animated gifs below: ![observability-sorting](https://user-images.githubusercontent.com/4459398/127960825-5be21a92-81c1-487d-9c62-1335495f4561.gif) _Above: Sorting in Observability, via `EuiDataGrid`'s sort popover_ ![security-solution-sorting](https://user-images.githubusercontent.com/4459398/128050301-0ea9ccbc-7896-46ef-96da-17b5b6d2e34b.gif) _Above: Sorting and hiding columns in the Security Solution via `EuiDataGrid`'s column header actions_ ## Details * Sorting is disabled for non-aggregatble fields * This PR resolves the `Sort [Object Object]` TODO described [here](https://github.com/elastic/kibana/pull/106199#issuecomment-883668966) * ~This PR restores the column header tooltips where the TGrid is used in the Security Solution~ ## Desk testing To desk test this PR, you must enable feature flags in the Observability and Security Solution: - To desk test the `Observability > Alerts` page, add the following settings to `config/kibana.dev.yml`: ``` xpack.observability.unsafe.cases.enabled: true xpack.observability.unsafe.alertingExperience.enabled: true xpack.ruleRegistry.write.enabled: true ``` - To desk test the TGrid in the following Security Solution, edit `x-pack/plugins/security_solution/common/experimental_features.ts` and in the `allowedExperimentalValues` section set: ```typescript tGridEnabled: true, ``` cc @mdefazio --- .../common/types/timeline/columns/index.ts | 8 +- .../body/column_headers/helpers.test.ts | 116 ------ .../body/column_headers/helpers.test.tsx | 258 ++++++++++++ .../{helpers.ts => helpers.tsx} | 50 ++- .../components/t_grid/body/helpers.test.ts | 178 -------- .../components/t_grid/body/helpers.test.tsx | 391 ++++++++++++++++++ .../public/components/t_grid/body/helpers.tsx | 127 +++++- .../components/t_grid/body/index.test.tsx | 1 + .../public/components/t_grid/body/index.tsx | 47 ++- .../components/t_grid/integrated/index.tsx | 2 +- .../components/t_grid/standalone/index.tsx | 10 +- .../public/store/t_grid/helpers.test.tsx | 43 ++ .../timelines/public/store/t_grid/helpers.ts | 2 +- .../timelines/public/store/t_grid/model.ts | 1 + 14 files changed, 920 insertions(+), 314 deletions(-) delete mode 100644 x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts create mode 100644 x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.tsx rename x-pack/plugins/timelines/public/components/t_grid/body/column_headers/{helpers.ts => helpers.tsx} (53%) delete mode 100644 x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts create mode 100644 x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx create mode 100644 x-pack/plugins/timelines/public/store/t_grid/helpers.test.tsx diff --git a/x-pack/plugins/timelines/common/types/timeline/columns/index.ts b/x-pack/plugins/timelines/common/types/timeline/columns/index.ts index 9161ea3ea78ce..693cc1d842ee4 100644 --- a/x-pack/plugins/timelines/common/types/timeline/columns/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/columns/index.ts @@ -17,7 +17,13 @@ export type ColumnId = string; /** The specification of a column header */ export type ColumnHeaderOptions = Pick< EuiDataGridColumn, - 'display' | 'displayAsText' | 'id' | 'initialWidth' + | 'actions' + | 'defaultSortDirection' + | 'display' + | 'displayAsText' + | 'id' + | 'initialWidth' + | 'isSortable' > & { aggregatable?: boolean; category?: string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts deleted file mode 100644 index d19f221966e55..0000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * 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 { defaultHeaders } from './default_headers'; -import { getActionsColumnWidth, getColumnWidthFromType, getColumnHeaders } from './helpers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_DATE_COLUMN_MIN_WIDTH, - DEFAULT_ACTIONS_COLUMN_WIDTH, - EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, - SHOW_CHECK_BOXES_COLUMN_WIDTH, -} from '../constants'; -import { mockBrowserFields } from '../../../../mock/browser_fields'; - -window.matchMedia = jest.fn().mockImplementation((query) => { - return { - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - }; -}); - -describe('helpers', () => { - describe('getColumnWidthFromType', () => { - test('it returns the expected width for a non-date column', () => { - expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH); - }); - - test('it returns the expected width for a date column', () => { - expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH); - }); - }); - - describe('getActionsColumnWidth', () => { - test('returns the default actions column width when isEventViewer is false', () => { - expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH); - }); - - test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => { - expect(getActionsColumnWidth(false, true)).toEqual( - DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH - ); - }); - - test('returns the events viewer actions column width when isEventViewer is true', () => { - expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH); - }); - - test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { - expect(getActionsColumnWidth(true, true)).toEqual( - EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH - ); - }); - }); - - describe('getColumnHeaders', () => { - test('should return a full object of ColumnHeader from the default header', () => { - const expectedData = [ - { - aggregatable: true, - category: 'base', - columnHeaderType: 'not-filtered', - description: - 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', - example: '2016-05-23T08:05:34.853Z', - format: '', - id: '@timestamp', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: '@timestamp', - searchable: true, - type: 'date', - initialWidth: 190, - }, - { - aggregatable: true, - category: 'source', - columnHeaderType: 'not-filtered', - description: 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.', - example: '', - format: '', - id: 'source.ip', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'source.ip', - searchable: true, - type: 'ip', - initialWidth: 180, - }, - { - aggregatable: true, - category: 'destination', - columnHeaderType: 'not-filtered', - description: - 'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.', - example: '', - format: '', - id: 'destination.ip', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'destination.ip', - searchable: true, - type: 'ip', - initialWidth: 180, - }, - ]; - const mockHeader = defaultHeaders.filter((h) => - ['@timestamp', 'source.ip', 'destination.ip'].includes(h.id) - ); - expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); - }); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.tsx new file mode 100644 index 0000000000000..1e4bae156299b --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.tsx @@ -0,0 +1,258 @@ +/* + * 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 { mount } from 'enzyme'; +import { omit, set } from 'lodash/fp'; +import React from 'react'; + +import { defaultHeaders } from './default_headers'; +import { getActionsColumnWidth, getColumnWidthFromType, getColumnHeaders } from './helpers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_DATE_COLUMN_MIN_WIDTH, + DEFAULT_ACTIONS_COLUMN_WIDTH, + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, + SHOW_CHECK_BOXES_COLUMN_WIDTH, +} from '../constants'; +import { mockBrowserFields } from '../../../../mock/browser_fields'; + +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +describe('helpers', () => { + describe('getColumnWidthFromType', () => { + test('it returns the expected width for a non-date column', () => { + expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH); + }); + + test('it returns the expected width for a date column', () => { + expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH); + }); + }); + + describe('getActionsColumnWidth', () => { + test('returns the default actions column width when isEventViewer is false', () => { + expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH); + }); + + test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => { + expect(getActionsColumnWidth(false, true)).toEqual( + DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + ); + }); + + test('returns the events viewer actions column width when isEventViewer is true', () => { + expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH); + }); + + test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { + expect(getActionsColumnWidth(true, true)).toEqual( + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + ); + }); + }); + + describe('getColumnHeaders', () => { + // additional properties used by `EuiDataGrid`: + const actions = { + showSortAsc: { + label: 'Sort A-Z', + }, + showSortDesc: { + label: 'Sort Z-A', + }, + }; + const defaultSortDirection = 'desc'; + const isSortable = true; + + const mockHeader = defaultHeaders.filter((h) => + ['@timestamp', 'source.ip', 'destination.ip'].includes(h.id) + ); + + describe('display', () => { + const renderedByDisplay = 'I am rendered via a React component: header.display'; + const renderedByDisplayAsText = 'I am rendered by header.displayAsText'; + + test('it renders via `display` when the header has JUST a `display` property (`displayAsText` is undefined)', () => { + const headerWithJustDisplay = mockHeader.map((x) => + x.id === '@timestamp' + ? { + ...x, + display: {renderedByDisplay}, + } + : x + ); + + const wrapper = mount( + <>{getColumnHeaders(headerWithJustDisplay, mockBrowserFields)[0].display} + ); + + expect(wrapper.text()).toEqual(renderedByDisplay); + }); + + test('it (also) renders via `display` when the header has BOTH a `display` property AND a `displayAsText`', () => { + const headerWithBoth = mockHeader.map((x) => + x.id === '@timestamp' + ? { + ...x, + display: {renderedByDisplay}, // this has a higher priority... + displayAsText: renderedByDisplayAsText, // ...so this text won't be rendered + } + : x + ); + + const wrapper = mount( + <>{getColumnHeaders(headerWithBoth, mockBrowserFields)[0].display} + ); + + expect(wrapper.text()).toEqual(renderedByDisplay); + }); + + test('it renders via `displayAsText` when the header does NOT have a `display`, BUT it has `displayAsText`', () => { + const headerWithJustDisplayAsText = mockHeader.map((x) => + x.id === '@timestamp' + ? { + ...x, + displayAsText: renderedByDisplayAsText, // fallback to rendering via displayAsText + } + : x + ); + + const wrapper = mount( + <>{getColumnHeaders(headerWithJustDisplayAsText, mockBrowserFields)[0].display} + ); + + expect(wrapper.text()).toEqual(renderedByDisplayAsText); + }); + + test('it renders `header.id` when the header does NOT have a `display`, AND it does NOT have a `displayAsText`', () => { + const wrapper = mount(<>{getColumnHeaders(mockHeader, mockBrowserFields)[0].display}); + + expect(wrapper.text()).toEqual('@timestamp'); // fallback to rendering by header.id + }); + }); + + test('it renders the default actions when the header does NOT have custom actions', () => { + expect(getColumnHeaders(mockHeader, mockBrowserFields)[0].actions).toEqual(actions); + }); + + test('it renders custom actions when `actions` is defined in the header', () => { + const customActions = { + showSortAsc: { + label: 'A custom sort ascending', + }, + showSortDesc: { + label: 'A custom sort descending', + }, + }; + + const headerWithCustomActions = mockHeader.map((x) => + x.id === '@timestamp' + ? { + ...x, + actions: customActions, + } + : x + ); + + expect(getColumnHeaders(headerWithCustomActions, mockBrowserFields)[0].actions).toEqual( + customActions + ); + }); + + describe('isSortable', () => { + test("it is sortable, because `@timestamp`'s `aggregatable` BrowserFields property is `true`", () => { + expect(getColumnHeaders(mockHeader, mockBrowserFields)[0].isSortable).toEqual(true); + }); + + test("it is NOT sortable, when `@timestamp`'s `aggregatable` BrowserFields property is `false`", () => { + const withAggregatableOverride = set( + 'base.fields.@timestamp.aggregatable', + false, // override `aggregatable` for `@timestamp`, a date field that is normally aggregatable + mockBrowserFields + ); + + expect(getColumnHeaders(mockHeader, withAggregatableOverride)[0].isSortable).toEqual(false); + }); + + test('it is NOT sortable when BrowserFields does not have metadata for the field', () => { + const noBrowserFieldEntry = omit('base', mockBrowserFields); // omit the 'base` category, which contains `@timestamp` + + expect(getColumnHeaders(mockHeader, noBrowserFieldEntry)[0].isSortable).toEqual(false); + }); + }); + + test('should return a full object of ColumnHeader from the default header', () => { + const expectedData = [ + { + actions, + aggregatable: true, + category: 'base', + columnHeaderType: 'not-filtered', + defaultSortDirection, + description: + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', + example: '2016-05-23T08:05:34.853Z', + format: '', + id: '@timestamp', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + isSortable, + name: '@timestamp', + searchable: true, + type: 'date', + initialWidth: 190, + }, + { + actions, + aggregatable: true, + category: 'source', + columnHeaderType: 'not-filtered', + defaultSortDirection, + description: 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + id: 'source.ip', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + isSortable, + name: 'source.ip', + searchable: true, + type: 'ip', + initialWidth: 180, + }, + { + actions, + aggregatable: true, + category: 'destination', + columnHeaderType: 'not-filtered', + defaultSortDirection, + description: + 'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + id: 'destination.ip', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + isSortable, + name: 'destination.ip', + searchable: true, + type: 'ip', + initialWidth: 180, + }, + ]; + + // NOTE: the omitted `display` (`React.ReactNode`) property is tested separately above + expect(getColumnHeaders(mockHeader, mockBrowserFields).map(omit('display'))).toEqual( + expectedData + ); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.tsx similarity index 53% rename from x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts rename to x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.tsx index 6c793e132b7e3..97b947b4344e1 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.tsx @@ -5,10 +5,15 @@ * 2.0. */ -import { get } from 'lodash/fp'; -import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; -import type { ColumnHeaderOptions } from '../../../../../common/types/timeline'; +import { EuiDataGridColumnActions } from '@elastic/eui'; +import { get, keyBy } from 'lodash/fp'; +import React from 'react'; +import type { + BrowserField, + BrowserFields, +} from '../../../../../common/search_strategy/index_fields'; +import type { ColumnHeaderOptions } from '../../../../../common/types/timeline'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, @@ -17,6 +22,27 @@ import { DEFAULT_ACTIONS_COLUMN_WIDTH, MINIMUM_ACTIONS_COLUMN_WIDTH, } from '../constants'; +import { allowSorting } from '../helpers'; +import * as i18n from './translations'; + +const defaultActions: EuiDataGridColumnActions = { + showSortAsc: { label: i18n.SORT_AZ }, + showSortDesc: { label: i18n.SORT_ZA }, +}; + +const getAllBrowserFields = (browserFields: BrowserFields): Array> => + Object.values(browserFields).reduce>>( + (acc, namespace) => [ + ...acc, + ...Object.values(namespace.fields != null ? namespace.fields : {}), + ], + [] + ); + +const getAllFieldsByName = ( + browserFields: BrowserFields +): { [fieldName: string]: Partial } => + keyBy('name', getAllBrowserFields(browserFields)); /** Enriches the column headers with field details from the specified browserFields */ export const getColumnHeaders = ( @@ -26,13 +52,29 @@ export const getColumnHeaders = ( return headers ? headers.map((header) => { const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - return { + + // augment the header with metadata from browserFields: + const augmentedHeader = { ...header, ...get( [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], browserFields ), }; + + const content = <>{header.display ?? header.displayAsText ?? header.id}; + + // return the augmentedHeader with additional properties used by `EuiDataGrid` + return { + ...augmentedHeader, + actions: header.actions ?? defaultActions, + defaultSortDirection: 'desc', // the default action when a user selects a field via `EuiDataGrid`'s `Pick fields to sort by` UI + display: <>{content}, + isSortable: allowSorting({ + browserField: getAllFieldsByName(browserFields)[header.id], + fieldName: header.id, + }), + }; }) : []; }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts deleted file mode 100644 index ffdf91425c4f7..0000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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 { Ecs } from '../../../../common/ecs'; -import { stringifyEvent } from './helpers'; - -describe('helpers', () => { - describe('stringifyEvent', () => { - test('it omits __typename when it appears at arbitrary levels', () => { - const toStringify: Ecs = { - __typename: 'level 0', - _id: '4', - timestamp: '2018-11-08T19:03:25.937Z', - host: { - __typename: 'level 1', - name: ['suricata'], - ip: ['192.168.0.1'], - }, - event: { - id: ['4'], - category: ['Attempted Administrator Privilege Gain'], - type: ['Alert'], - module: ['suricata'], - severity: [1], - }, - source: { - ip: ['192.168.0.3'], - port: [53], - }, - destination: { - ip: ['192.168.0.3'], - port: [6343], - }, - suricata: { - eve: { - flow_id: [4], - proto: [''], - alert: { - signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], - signature_id: [4], - __typename: 'level 2', - }, - }, - }, - user: { - id: ['4'], - name: ['jack.black'], - }, - geo: { - region_name: ['neither'], - country_iso_code: ['sasquatch'], - }, - } as Ecs; // as cast so that `__typename` can be added for the tests even though it is not part of ECS - const expected: Ecs = { - _id: '4', - timestamp: '2018-11-08T19:03:25.937Z', - host: { - name: ['suricata'], - ip: ['192.168.0.1'], - }, - event: { - id: ['4'], - category: ['Attempted Administrator Privilege Gain'], - type: ['Alert'], - module: ['suricata'], - severity: [1], - }, - source: { - ip: ['192.168.0.3'], - port: [53], - }, - destination: { - ip: ['192.168.0.3'], - port: [6343], - }, - suricata: { - eve: { - flow_id: [4], - proto: [''], - alert: { - signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], - signature_id: [4], - }, - }, - }, - user: { - id: ['4'], - name: ['jack.black'], - }, - geo: { - region_name: ['neither'], - country_iso_code: ['sasquatch'], - }, - }; - expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); - }); - - test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => { - const expected: Ecs = { - _id: '4', - host: {}, - event: { - id: ['4'], - category: ['theory'], - type: ['Alert'], - module: ['me'], - severity: [1], - }, - source: { - port: [53], - }, - destination: { - ip: ['192.168.0.3'], - port: [6343], - }, - suricata: { - eve: { - flow_id: [4], - proto: [''], - alert: { - signature: ['dance moves'], - }, - }, - }, - user: { - id: ['4'], - name: ['no use for a'], - }, - geo: { - region_name: ['bizzaro'], - country_iso_code: ['world'], - }, - }; - const toStringify: Ecs = { - _id: '4', - host: {}, - event: { - id: ['4'], - category: ['theory'], - type: ['Alert'], - module: ['me'], - severity: [1], - }, - source: { - ip: undefined, - port: [53], - }, - destination: { - ip: ['192.168.0.3'], - port: [6343], - }, - suricata: { - eve: { - flow_id: [4], - proto: [''], - alert: { - signature: ['dance moves'], - signature_id: undefined, - }, - }, - }, - user: { - id: ['4'], - name: ['no use for a'], - }, - geo: { - region_name: ['bizzaro'], - country_iso_code: ['world'], - }, - }; - expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); - }); - }); -}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx new file mode 100644 index 0000000000000..fe9c5ea2bc332 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx @@ -0,0 +1,391 @@ +/* + * 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 { omit } from 'lodash/fp'; + +import { ColumnHeaderOptions } from '../../../../common'; +import { Ecs } from '../../../../common/ecs'; +import { + allowSorting, + mapSortDirectionToDirection, + mapSortingColumns, + stringifyEvent, +} from './helpers'; + +describe('helpers', () => { + describe('stringifyEvent', () => { + test('it omits __typename when it appears at arbitrary levels', () => { + const toStringify: Ecs = { + __typename: 'level 0', + _id: '4', + timestamp: '2018-11-08T19:03:25.937Z', + host: { + __typename: 'level 1', + name: ['suricata'], + ip: ['192.168.0.1'], + }, + event: { + id: ['4'], + category: ['Attempted Administrator Privilege Gain'], + type: ['Alert'], + module: ['suricata'], + severity: [1], + }, + source: { + ip: ['192.168.0.3'], + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], + signature_id: [4], + __typename: 'level 2', + }, + }, + }, + user: { + id: ['4'], + name: ['jack.black'], + }, + geo: { + region_name: ['neither'], + country_iso_code: ['sasquatch'], + }, + } as Ecs; // as cast so that `__typename` can be added for the tests even though it is not part of ECS + const expected: Ecs = { + _id: '4', + timestamp: '2018-11-08T19:03:25.937Z', + host: { + name: ['suricata'], + ip: ['192.168.0.1'], + }, + event: { + id: ['4'], + category: ['Attempted Administrator Privilege Gain'], + type: ['Alert'], + module: ['suricata'], + severity: [1], + }, + source: { + ip: ['192.168.0.3'], + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], + signature_id: [4], + }, + }, + }, + user: { + id: ['4'], + name: ['jack.black'], + }, + geo: { + region_name: ['neither'], + country_iso_code: ['sasquatch'], + }, + }; + expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); + }); + + test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => { + const expected: Ecs = { + _id: '4', + host: {}, + event: { + id: ['4'], + category: ['theory'], + type: ['Alert'], + module: ['me'], + severity: [1], + }, + source: { + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['dance moves'], + }, + }, + }, + user: { + id: ['4'], + name: ['no use for a'], + }, + geo: { + region_name: ['bizzaro'], + country_iso_code: ['world'], + }, + }; + const toStringify: Ecs = { + _id: '4', + host: {}, + event: { + id: ['4'], + category: ['theory'], + type: ['Alert'], + module: ['me'], + severity: [1], + }, + source: { + ip: undefined, + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['dance moves'], + signature_id: undefined, + }, + }, + }, + user: { + id: ['4'], + name: ['no use for a'], + }, + geo: { + region_name: ['bizzaro'], + country_iso_code: ['world'], + }, + }; + expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); + }); + }); + + describe('mapSortDirectionToDirection', () => { + test('it returns the expected direction when sortDirection is `asc`', () => { + expect(mapSortDirectionToDirection('asc')).toBe('asc'); + }); + + test('it returns the expected direction when sortDirection is `desc`', () => { + expect(mapSortDirectionToDirection('desc')).toBe('desc'); + }); + + test('it returns the expected direction when sortDirection is `none`', () => { + expect(mapSortDirectionToDirection('none')).toBe('desc'); // defaults to a valid direction accepted by `EuiDataGrid` + }); + }); + + describe('mapSortingColumns', () => { + const columns: Array<{ + id: string; + direction: 'asc' | 'desc'; + }> = [ + { + id: 'kibana.rac.alert.status', + direction: 'asc', + }, + { + id: 'kibana.rac.alert.start', + direction: 'desc', + }, + ]; + + const columnHeaders: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + displayAsText: 'Status', + id: 'kibana.rac.alert.status', + initialWidth: 79, + category: 'kibana', + type: 'string', + aggregatable: true, + actions: { + showSortAsc: { + label: 'Sort A-Z', + }, + showSortDesc: { + label: 'Sort Z-A', + }, + }, + defaultSortDirection: 'desc', + display: { + key: null, + ref: null, + props: { + children: { + key: null, + ref: null, + props: { + children: 'Status', + }, + _owner: null, + }, + }, + _owner: null, + }, + isSortable: true, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Triggered', + id: 'kibana.rac.alert.start', + initialWidth: 176, + category: 'kibana', + type: 'date', + aggregatable: true, + actions: { + showSortAsc: { + label: 'Sort A-Z', + }, + showSortDesc: { + label: 'Sort Z-A', + }, + }, + defaultSortDirection: 'desc', + display: { + key: null, + ref: null, + props: { + children: { + key: null, + ref: null, + props: { + children: 'Triggered', + }, + _owner: null, + }, + }, + _owner: null, + }, + isSortable: true, + }, + ]; + + test('it returns the expected results when each column has a corresponding entry in `columnHeaders`', () => { + expect(mapSortingColumns({ columns, columnHeaders })).toEqual([ + { columnId: 'kibana.rac.alert.status', columnType: 'string', sortDirection: 'asc' }, + { columnId: 'kibana.rac.alert.start', columnType: 'date', sortDirection: 'desc' }, + ]); + }); + + test('it defaults to a `columnType` of `text` when a column does NOT has a corresponding entry in `columnHeaders`', () => { + const withUnknownColumn: Array<{ + id: string; + direction: 'asc' | 'desc'; + }> = [ + { + id: 'kibana.rac.alert.status', + direction: 'asc', + }, + { + id: 'kibana.rac.alert.start', + direction: 'desc', + }, + { + id: 'unknown', // <-- no entry for this in `columnHeaders` + direction: 'asc', + }, + ]; + + expect(mapSortingColumns({ columns: withUnknownColumn, columnHeaders })).toEqual([ + { columnId: 'kibana.rac.alert.status', columnType: 'string', sortDirection: 'asc' }, + { columnId: 'kibana.rac.alert.start', columnType: 'date', sortDirection: 'desc' }, + { + columnId: 'unknown', + columnType: 'text', // <-- mapped to the default + sortDirection: 'asc', + }, + ]); + }); + }); + + describe('allowSorting', () => { + const aggregatableField = { + category: 'cloud', + description: + 'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.', + example: '666777888999', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'cloud.account.id', + searchable: true, + type: 'string', + aggregatable: true, // <-- allow sorting when this is true + format: '', + }; + + test('it returns true for an aggregatable field', () => { + expect( + allowSorting({ + browserField: aggregatableField, + fieldName: aggregatableField.name, + }) + ).toBe(true); + }); + + test('it returns true for a allow-listed non-BrowserField', () => { + expect( + allowSorting({ + browserField: undefined, // no BrowserField metadata for this field + fieldName: 'signal.rule.name', // an allow-listed field name + }) + ).toBe(true); + }); + + test('it returns false for a NON-aggregatable field (aggregatable is false)', () => { + const nonaggregatableField = { + ...aggregatableField, + aggregatable: false, // <-- NON-aggregatable + }; + + expect( + allowSorting({ + browserField: nonaggregatableField, + fieldName: nonaggregatableField.name, + }) + ).toBe(false); + }); + + test('it returns false if the BrowserField is missing the aggregatable property', () => { + const missingAggregatable = omit('aggregatable', aggregatableField); + + expect( + allowSorting({ + browserField: missingAggregatable, + fieldName: missingAggregatable.name, + }) + ).toBe(false); + }); + + test("it returns false for a non-allowlisted field we don't have `BrowserField` metadata for it", () => { + expect( + allowSorting({ + browserField: undefined, // <-- no metadata for this field + fieldName: 'non-allowlisted', + }) + ).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx index 85edefc0c0fa6..fb50d5ebabb8c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx @@ -8,8 +8,17 @@ import { isEmpty } from 'lodash/fp'; import type { Ecs } from '../../../../common/ecs'; -import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy'; -import type { TimelineEventsType } from '../../../../common/types/timeline'; +import type { + BrowserField, + TimelineItem, + TimelineNonEcsData, +} from '../../../../common/search_strategy'; +import type { + ColumnHeaderOptions, + SortColumnTimeline, + SortDirection, + TimelineEventsType, +} from '../../../../common/types/timeline'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => @@ -62,3 +71,117 @@ export const getEventType = (event: Ecs): Omit => { } return 'raw'; }; + +/** Maps (Redux) `SortDirection` to the `direction` values used by `EuiDataGrid` */ +export const mapSortDirectionToDirection = (sortDirection: SortDirection): 'asc' | 'desc' => { + switch (sortDirection) { + case 'asc': // fall through + case 'desc': + return sortDirection; + default: + return 'desc'; + } +}; + +/** + * Maps `EuiDataGrid` columns to their Redux representation by combining the + * `columns` with metadata from `columnHeaders` + */ +export const mapSortingColumns = ({ + columns, + columnHeaders, +}: { + columnHeaders: ColumnHeaderOptions[]; + columns: Array<{ + id: string; + direction: 'asc' | 'desc'; + }>; +}): SortColumnTimeline[] => + columns.map(({ id, direction }) => ({ + columnId: id, + columnType: columnHeaders.find((ch) => ch.id === id)?.type ?? 'text', + sortDirection: direction, + })); + +export const allowSorting = ({ + browserField, + fieldName, +}: { + browserField: Partial | undefined; + fieldName: string; +}): boolean => { + const isAggregatable = browserField?.aggregatable ?? false; + + const isAllowlistedNonBrowserField = [ + 'signal.ancestors.depth', + 'signal.ancestors.id', + 'signal.ancestors.rule', + 'signal.ancestors.type', + 'signal.original_event.action', + 'signal.original_event.category', + 'signal.original_event.code', + 'signal.original_event.created', + 'signal.original_event.dataset', + 'signal.original_event.duration', + 'signal.original_event.end', + 'signal.original_event.hash', + 'signal.original_event.id', + 'signal.original_event.kind', + 'signal.original_event.module', + 'signal.original_event.original', + 'signal.original_event.outcome', + 'signal.original_event.provider', + 'signal.original_event.risk_score', + 'signal.original_event.risk_score_norm', + 'signal.original_event.sequence', + 'signal.original_event.severity', + 'signal.original_event.start', + 'signal.original_event.timezone', + 'signal.original_event.type', + 'signal.original_time', + 'signal.parent.depth', + 'signal.parent.id', + 'signal.parent.index', + 'signal.parent.rule', + 'signal.parent.type', + 'signal.rule.created_by', + 'signal.rule.description', + 'signal.rule.enabled', + 'signal.rule.false_positives', + 'signal.rule.filters', + 'signal.rule.from', + 'signal.rule.id', + 'signal.rule.immutable', + 'signal.rule.index', + 'signal.rule.interval', + 'signal.rule.language', + 'signal.rule.max_signals', + 'signal.rule.name', + 'signal.rule.note', + 'signal.rule.output_index', + 'signal.rule.query', + 'signal.rule.references', + 'signal.rule.risk_score', + 'signal.rule.rule_id', + 'signal.rule.saved_id', + 'signal.rule.severity', + 'signal.rule.size', + 'signal.rule.tags', + 'signal.rule.threat', + 'signal.rule.threat.tactic.id', + 'signal.rule.threat.tactic.name', + 'signal.rule.threat.tactic.reference', + 'signal.rule.threat.technique.id', + 'signal.rule.threat.technique.name', + 'signal.rule.threat.technique.reference', + 'signal.rule.timeline_id', + 'signal.rule.timeline_title', + 'signal.rule.to', + 'signal.rule.type', + 'signal.rule.updated_by', + 'signal.rule.version', + 'signal.status', + ].includes(fieldName); + + return isAllowlistedNonBrowserField || isAggregatable; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx index 81fe117e08ccd..db5ec7646977d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -67,6 +67,7 @@ describe('Body', () => { id: 'timeline-test', isSelectAllChecked: false, loadingEventIds: [], + loadPage: jest.fn(), renderCellValue: TestCellRenderer, rowRenderers: [], selectedEventIds: {}, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 91667e74ae158..c9390dcf1985a 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -24,7 +24,7 @@ import React, { useMemo, useState, } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { TimelineId, @@ -43,8 +43,7 @@ import type { import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { getActionsColumnWidth, getColumnHeaders } from './column_headers/helpers'; -import { getEventIdToDataMapping } from './helpers'; -import { Sort } from './sort'; +import { getEventIdToDataMapping, mapSortDirectionToDirection, mapSortingColumns } from './helpers'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; @@ -71,9 +70,9 @@ interface OwnProps { isEventViewer?: boolean; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; - sort: Sort[]; tabType: TimelineTabs; leadingControlColumns?: ControlColumnProps[]; + loadPage: (newActivePage: number) => void; trailingControlColumns?: ControlColumnProps[]; totalPages: number; totalItems: number; @@ -217,6 +216,7 @@ export const BodyComponent = React.memo( isEventViewer = false, isSelectAllChecked, loadingEventIds, + loadPage, selectedEventIds, setSelected, clearSelected, @@ -235,6 +235,7 @@ export const BodyComponent = React.memo( trailingControlColumns = EMPTY_CONTROL_COLUMNS, refetch, }) => { + const dispatch = useDispatch(); const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); const { queryFields, selectAll } = useDeepEqualSelector((state) => getManageTimeline(state, id) @@ -366,13 +367,41 @@ export const BodyComponent = React.memo( ] ); - const [sortingColumns, setSortingColumns] = useState([]); + const sortingColumns: Array<{ + id: string; + direction: 'asc' | 'desc'; + }> = useMemo( + () => + sort.map((x) => ({ + id: x.columnId, + direction: mapSortDirectionToDirection(x.sortDirection), + })), + [sort] + ); const onSort = useCallback( - (columns) => { - setSortingColumns(columns); + ( + nextSortingColumns: Array<{ + id: string; + direction: 'asc' | 'desc'; + }> + ) => { + dispatch( + tGridActions.updateSort({ + id, + sort: mapSortingColumns({ columns: nextSortingColumns, columnHeaders }), + }) + ); + + setTimeout(() => { + // schedule the query to be re-executed from page 0, (but only after the + // store has been updated with the new sort): + if (loadPage != null) { + loadPage(0); + } + }, 0); }, - [setSortingColumns] + [columnHeaders, dispatch, id, loadPage] ); const [visibleColumns, setVisibleColumns] = useState(() => @@ -498,6 +527,7 @@ const makeMapStateToProps = () => { loadingEventIds, selectedEventIds, showCheckboxes, + sort, } = timeline; return { @@ -508,6 +538,7 @@ const makeMapStateToProps = () => { id, selectedEventIds, showCheckboxes, + sort, }; }; return mapStateToProps; diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index 924b83ab6a9e2..2b3de6ac78fe5 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -311,10 +311,10 @@ const TGridIntegratedComponent: React.FC = ({ data={nonDeletedEvents} id={id} isEventViewer={true} + loadPage={loadPage} onRuleChange={onRuleChange} renderCellValue={renderCellValue} rowRenderers={rowRenderers} - sort={sort} tabType={TimelineTabs.query} totalPages={calculateTotalPages({ itemsCount: totalCountMinusDeleted, diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 06543cdc6d99a..7caa6479a583c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -143,7 +143,7 @@ const TGridStandaloneComponent: React.FC = ({ rowRenderers, setRefetch, start, - sort, + sort: initialSort, graphEventId, leadingControlColumns, trailingControlColumns, @@ -161,6 +161,7 @@ const TGridStandaloneComponent: React.FC = ({ itemsPerPage: itemsPerPageStore, itemsPerPageOptions: itemsPerPageOptionsStore, queryFields, + sort: sortFromRedux, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); @@ -201,6 +202,9 @@ const TGridStandaloneComponent: React.FC = ({ [columnsHeader, queryFields] ); + const [sort, setSort] = useState(initialSort); + useEffect(() => setSort(sortFromRedux), [sortFromRedux]); + const sortField = useMemo( () => sort.map(({ columnId, columnType, sortDirection }) => ({ @@ -267,7 +271,6 @@ const TGridStandaloneComponent: React.FC = ({ end, }, indexNames, - sort, itemsPerPage, itemsPerPageOptions, showCheckboxes: true, @@ -279,6 +282,7 @@ const TGridStandaloneComponent: React.FC = ({ defaultColumns: columns, footerText, loadingText, + sort, unit, }) ); @@ -320,10 +324,10 @@ const TGridStandaloneComponent: React.FC = ({ data={nonDeletedEvents} id={STANDALONE_ID} isEventViewer={true} + loadPage={loadPage} onRuleChange={onRuleChange} renderCellValue={renderCellValue} rowRenderers={rowRenderers} - sort={sort} tabType={TimelineTabs.query} totalPages={calculateTotalPages({ itemsCount: totalCountMinusDeleted, diff --git a/x-pack/plugins/timelines/public/store/t_grid/helpers.test.tsx b/x-pack/plugins/timelines/public/store/t_grid/helpers.test.tsx new file mode 100644 index 0000000000000..0c492ad8f8a59 --- /dev/null +++ b/x-pack/plugins/timelines/public/store/t_grid/helpers.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { SortColumnTimeline } from '../../../common'; +import { tGridDefaults } from './defaults'; +import { setInitializeTgridSettings } from './helpers'; +import { mockGlobalState } from '../../mock/global_state'; + +import { TGridModelSettings } from '.'; + +const id = 'foo'; +const timelineById = { + ...mockGlobalState.timelineById, +}; + +describe('setInitializeTgridSettings', () => { + test('it returns the expected sort when tGridSettingsProps has an override', () => { + const sort: SortColumnTimeline[] = [ + { columnId: 'foozle', columnType: 'date', sortDirection: 'asc' }, + ]; + + const tGridSettingsProps: Partial = { + footerText: 'test', + sort, // <-- override + }; + + expect(setInitializeTgridSettings({ id, timelineById, tGridSettingsProps })[id].sort).toEqual( + sort + ); + }); + + test('it returns the default sort when tGridSettingsProps does NOT contain an override', () => { + const tGridSettingsProps = { footerText: 'test' }; // <-- no `sort` override + + expect(setInitializeTgridSettings({ id, timelineById, tGridSettingsProps })[id].sort).toEqual( + tGridDefaults.sort + ); + }); +}); diff --git a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts index dd056f1e9237a..c7c015a283b75 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts @@ -170,7 +170,7 @@ export const setInitializeTgridSettings = ({ ...(!timeline || (isEmpty(timeline.columns) && !isEmpty(tGridSettingsProps.defaultColumns)) ? { columns: tGridSettingsProps.defaultColumns } : {}), - sort: tGridDefaults.sort, + sort: tGridSettingsProps.sort ?? tGridDefaults.sort, loadingEventIds: tGridDefaults.loadingEventIds, }, }; diff --git a/x-pack/plugins/timelines/public/store/t_grid/model.ts b/x-pack/plugins/timelines/public/store/t_grid/model.ts index b3d48115eb490..1019b1ca4a7af 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/model.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/model.ts @@ -31,6 +31,7 @@ export interface TGridModelSettings { queryFields: string[]; selectAll: boolean; showCheckboxes?: boolean; + sort: SortColumnTimeline[]; title: string; unit?: (n: number) => string | React.ReactNode; }