Skip to content

Commit 30ddff5

Browse files
[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](#106199 (comment)) * ~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
1 parent 6fbc223 commit 30ddff5

14 files changed

Lines changed: 920 additions & 314 deletions

File tree

x-pack/plugins/timelines/common/types/timeline/columns/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ export type ColumnId = string;
1717
/** The specification of a column header */
1818
export type ColumnHeaderOptions = Pick<
1919
EuiDataGridColumn,
20-
'display' | 'displayAsText' | 'id' | 'initialWidth'
20+
| 'actions'
21+
| 'defaultSortDirection'
22+
| 'display'
23+
| 'displayAsText'
24+
| 'id'
25+
| 'initialWidth'
26+
| 'isSortable'
2127
> & {
2228
aggregatable?: boolean;
2329
category?: string;

x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts

Lines changed: 0 additions & 116 deletions
This file was deleted.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
import { mount } from 'enzyme';
8+
import { omit, set } from 'lodash/fp';
9+
import React from 'react';
10+
11+
import { defaultHeaders } from './default_headers';
12+
import { getActionsColumnWidth, getColumnWidthFromType, getColumnHeaders } from './helpers';
13+
import {
14+
DEFAULT_COLUMN_MIN_WIDTH,
15+
DEFAULT_DATE_COLUMN_MIN_WIDTH,
16+
DEFAULT_ACTIONS_COLUMN_WIDTH,
17+
EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH,
18+
SHOW_CHECK_BOXES_COLUMN_WIDTH,
19+
} from '../constants';
20+
import { mockBrowserFields } from '../../../../mock/browser_fields';
21+
22+
window.matchMedia = jest.fn().mockImplementation((query) => {
23+
return {
24+
matches: false,
25+
media: query,
26+
onchange: null,
27+
addListener: jest.fn(),
28+
removeListener: jest.fn(),
29+
};
30+
});
31+
32+
describe('helpers', () => {
33+
describe('getColumnWidthFromType', () => {
34+
test('it returns the expected width for a non-date column', () => {
35+
expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH);
36+
});
37+
38+
test('it returns the expected width for a date column', () => {
39+
expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH);
40+
});
41+
});
42+
43+
describe('getActionsColumnWidth', () => {
44+
test('returns the default actions column width when isEventViewer is false', () => {
45+
expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH);
46+
});
47+
48+
test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => {
49+
expect(getActionsColumnWidth(false, true)).toEqual(
50+
DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH
51+
);
52+
});
53+
54+
test('returns the events viewer actions column width when isEventViewer is true', () => {
55+
expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH);
56+
});
57+
58+
test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => {
59+
expect(getActionsColumnWidth(true, true)).toEqual(
60+
EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH
61+
);
62+
});
63+
});
64+
65+
describe('getColumnHeaders', () => {
66+
// additional properties used by `EuiDataGrid`:
67+
const actions = {
68+
showSortAsc: {
69+
label: 'Sort A-Z',
70+
},
71+
showSortDesc: {
72+
label: 'Sort Z-A',
73+
},
74+
};
75+
const defaultSortDirection = 'desc';
76+
const isSortable = true;
77+
78+
const mockHeader = defaultHeaders.filter((h) =>
79+
['@timestamp', 'source.ip', 'destination.ip'].includes(h.id)
80+
);
81+
82+
describe('display', () => {
83+
const renderedByDisplay = 'I am rendered via a React component: header.display';
84+
const renderedByDisplayAsText = 'I am rendered by header.displayAsText';
85+
86+
test('it renders via `display` when the header has JUST a `display` property (`displayAsText` is undefined)', () => {
87+
const headerWithJustDisplay = mockHeader.map((x) =>
88+
x.id === '@timestamp'
89+
? {
90+
...x,
91+
display: <span>{renderedByDisplay}</span>,
92+
}
93+
: x
94+
);
95+
96+
const wrapper = mount(
97+
<>{getColumnHeaders(headerWithJustDisplay, mockBrowserFields)[0].display}</>
98+
);
99+
100+
expect(wrapper.text()).toEqual(renderedByDisplay);
101+
});
102+
103+
test('it (also) renders via `display` when the header has BOTH a `display` property AND a `displayAsText`', () => {
104+
const headerWithBoth = mockHeader.map((x) =>
105+
x.id === '@timestamp'
106+
? {
107+
...x,
108+
display: <span>{renderedByDisplay}</span>, // this has a higher priority...
109+
displayAsText: renderedByDisplayAsText, // ...so this text won't be rendered
110+
}
111+
: x
112+
);
113+
114+
const wrapper = mount(
115+
<>{getColumnHeaders(headerWithBoth, mockBrowserFields)[0].display}</>
116+
);
117+
118+
expect(wrapper.text()).toEqual(renderedByDisplay);
119+
});
120+
121+
test('it renders via `displayAsText` when the header does NOT have a `display`, BUT it has `displayAsText`', () => {
122+
const headerWithJustDisplayAsText = mockHeader.map((x) =>
123+
x.id === '@timestamp'
124+
? {
125+
...x,
126+
displayAsText: renderedByDisplayAsText, // fallback to rendering via displayAsText
127+
}
128+
: x
129+
);
130+
131+
const wrapper = mount(
132+
<>{getColumnHeaders(headerWithJustDisplayAsText, mockBrowserFields)[0].display}</>
133+
);
134+
135+
expect(wrapper.text()).toEqual(renderedByDisplayAsText);
136+
});
137+
138+
test('it renders `header.id` when the header does NOT have a `display`, AND it does NOT have a `displayAsText`', () => {
139+
const wrapper = mount(<>{getColumnHeaders(mockHeader, mockBrowserFields)[0].display}</>);
140+
141+
expect(wrapper.text()).toEqual('@timestamp'); // fallback to rendering by header.id
142+
});
143+
});
144+
145+
test('it renders the default actions when the header does NOT have custom actions', () => {
146+
expect(getColumnHeaders(mockHeader, mockBrowserFields)[0].actions).toEqual(actions);
147+
});
148+
149+
test('it renders custom actions when `actions` is defined in the header', () => {
150+
const customActions = {
151+
showSortAsc: {
152+
label: 'A custom sort ascending',
153+
},
154+
showSortDesc: {
155+
label: 'A custom sort descending',
156+
},
157+
};
158+
159+
const headerWithCustomActions = mockHeader.map((x) =>
160+
x.id === '@timestamp'
161+
? {
162+
...x,
163+
actions: customActions,
164+
}
165+
: x
166+
);
167+
168+
expect(getColumnHeaders(headerWithCustomActions, mockBrowserFields)[0].actions).toEqual(
169+
customActions
170+
);
171+
});
172+
173+
describe('isSortable', () => {
174+
test("it is sortable, because `@timestamp`'s `aggregatable` BrowserFields property is `true`", () => {
175+
expect(getColumnHeaders(mockHeader, mockBrowserFields)[0].isSortable).toEqual(true);
176+
});
177+
178+
test("it is NOT sortable, when `@timestamp`'s `aggregatable` BrowserFields property is `false`", () => {
179+
const withAggregatableOverride = set(
180+
'base.fields.@timestamp.aggregatable',
181+
false, // override `aggregatable` for `@timestamp`, a date field that is normally aggregatable
182+
mockBrowserFields
183+
);
184+
185+
expect(getColumnHeaders(mockHeader, withAggregatableOverride)[0].isSortable).toEqual(false);
186+
});
187+
188+
test('it is NOT sortable when BrowserFields does not have metadata for the field', () => {
189+
const noBrowserFieldEntry = omit('base', mockBrowserFields); // omit the 'base` category, which contains `@timestamp`
190+
191+
expect(getColumnHeaders(mockHeader, noBrowserFieldEntry)[0].isSortable).toEqual(false);
192+
});
193+
});
194+
195+
test('should return a full object of ColumnHeader from the default header', () => {
196+
const expectedData = [
197+
{
198+
actions,
199+
aggregatable: true,
200+
category: 'base',
201+
columnHeaderType: 'not-filtered',
202+
defaultSortDirection,
203+
description:
204+
'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.',
205+
example: '2016-05-23T08:05:34.853Z',
206+
format: '',
207+
id: '@timestamp',
208+
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
209+
isSortable,
210+
name: '@timestamp',
211+
searchable: true,
212+
type: 'date',
213+
initialWidth: 190,
214+
},
215+
{
216+
actions,
217+
aggregatable: true,
218+
category: 'source',
219+
columnHeaderType: 'not-filtered',
220+
defaultSortDirection,
221+
description: 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.',
222+
example: '',
223+
format: '',
224+
id: 'source.ip',
225+
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
226+
isSortable,
227+
name: 'source.ip',
228+
searchable: true,
229+
type: 'ip',
230+
initialWidth: 180,
231+
},
232+
{
233+
actions,
234+
aggregatable: true,
235+
category: 'destination',
236+
columnHeaderType: 'not-filtered',
237+
defaultSortDirection,
238+
description:
239+
'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.',
240+
example: '',
241+
format: '',
242+
id: 'destination.ip',
243+
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
244+
isSortable,
245+
name: 'destination.ip',
246+
searchable: true,
247+
type: 'ip',
248+
initialWidth: 180,
249+
},
250+
];
251+
252+
// NOTE: the omitted `display` (`React.ReactNode`) property is tested separately above
253+
expect(getColumnHeaders(mockHeader, mockBrowserFields).map(omit('display'))).toEqual(
254+
expectedData
255+
);
256+
});
257+
});
258+
});

0 commit comments

Comments
 (0)