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;
}