Skip to content

Commit

Permalink
[RAC] [TGrid] Implements sorting in the TGrid (#107495)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
andrew-goldstein committed Aug 3, 2021
1 parent 0bbba23 commit f4bc4d1
Show file tree
Hide file tree
Showing 14 changed files with 920 additions and 314 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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: <span>{renderedByDisplay}</span>,
}
: 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: <span>{renderedByDisplay}</span>, // 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
);
});
});
});
Loading

0 comments on commit f4bc4d1

Please sign in to comment.