Skip to content

Commit 3800acb

Browse files
committed
[Security Solutions] fix timeline tabs + layout (#86581)
* fix timeline tabs + fix screenreader * review * fix jest tests
1 parent 09e9b03 commit 3800acb

File tree

32 files changed

+409
-156
lines changed

32 files changed

+409
-156
lines changed

x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { mount } from 'enzyme';
8+
import React from 'react';
9+
10+
import {
11+
ariaIndexToArrayIndex,
12+
arrayIndexToAriaIndex,
13+
getNotesContainerClassName,
14+
getRowRendererClassName,
15+
isArrowRight,
16+
} from './helpers';
17+
18+
describe('helpers', () => {
19+
describe('ariaIndexToArrayIndex', () => {
20+
test('it returns the expected array index', () => {
21+
expect(ariaIndexToArrayIndex(1)).toEqual(0);
22+
});
23+
});
24+
25+
describe('arrayIndexToAriaIndex', () => {
26+
test('it returns the expected aria index', () => {
27+
expect(arrayIndexToAriaIndex(0)).toEqual(1);
28+
});
29+
});
30+
31+
describe('isArrowRight', () => {
32+
test('it returns true if the right arrow key was pressed', () => {
33+
let result = false;
34+
const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
35+
result = isArrowRight(keyboardEvent);
36+
};
37+
38+
const wrapper = mount(<div onKeyDown={onKeyDown} />);
39+
wrapper.find('div').simulate('keydown', { key: 'ArrowRight' });
40+
wrapper.update();
41+
42+
expect(result).toBe(true);
43+
});
44+
45+
test('it returns false if another key was pressed', () => {
46+
let result = false;
47+
const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
48+
result = isArrowRight(keyboardEvent);
49+
};
50+
51+
const wrapper = mount(<div onKeyDown={onKeyDown} />);
52+
wrapper.find('div').simulate('keydown', { key: 'Enter' });
53+
wrapper.update();
54+
55+
expect(result).toBe(false);
56+
});
57+
});
58+
59+
describe('getRowRendererClassName', () => {
60+
test('it returns the expected class name', () => {
61+
expect(getRowRendererClassName(2)).toBe('row-renderer-2');
62+
});
63+
});
64+
65+
describe('getNotesContainerClassName', () => {
66+
test('it returns the expected class name', () => {
67+
expect(getNotesContainerClassName(2)).toBe('notes-container-2');
68+
});
69+
});
70+
});

x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
*/
66

77
import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../drag_and_drop/helpers';
8+
import {
9+
NOTES_CONTAINER_CLASS_NAME,
10+
NOTE_CONTENT_CLASS_NAME,
11+
ROW_RENDERER_CLASS_NAME,
12+
} from '../../../timelines/components/timeline/body/helpers';
813
import { HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME } from '../with_hover_actions';
914

1015
/**
@@ -63,6 +68,9 @@ export const isArrowDownOrArrowUp = (event: React.KeyboardEvent): boolean =>
6368
export const isArrowKey = (event: React.KeyboardEvent): boolean =>
6469
isArrowRightOrArrowLeft(event) || isArrowDownOrArrowUp(event);
6570

71+
/** Returns `true` if the right arrow key was pressed */
72+
export const isArrowRight = (event: React.KeyboardEvent): boolean => event.key === 'ArrowRight';
73+
6674
/** Returns `true` if the escape key was pressed */
6775
export const isEscape = (event: React.KeyboardEvent): boolean => event.key === 'Escape';
6876

@@ -284,6 +292,12 @@ export type OnColumnFocused = ({
284292
newFocusedColumnAriaColindex: number | null;
285293
}) => void;
286294

295+
export const getRowRendererClassName = (ariaRowindex: number) =>
296+
`${ROW_RENDERER_CLASS_NAME}-${ariaRowindex}`;
297+
298+
export const getNotesContainerClassName = (ariaRowindex: number) =>
299+
`${NOTES_CONTAINER_CLASS_NAME}-${ariaRowindex}`;
300+
287301
/**
288302
* This function implements arrow key support for the `onKeyDownFocusHandler`.
289303
*
@@ -312,6 +326,28 @@ export const onArrowKeyDown = ({
312326
onColumnFocused?: OnColumnFocused;
313327
rowindexAttribute: string;
314328
}) => {
329+
if (isArrowDown(event) && event.shiftKey) {
330+
const firstRowRendererDraggable = containerElement?.querySelector<HTMLDivElement>(
331+
`.${getRowRendererClassName(focusedAriaRowindex)} .${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}`
332+
);
333+
334+
if (firstRowRendererDraggable) {
335+
firstRowRendererDraggable.focus();
336+
return;
337+
}
338+
}
339+
340+
if (isArrowRight(event) && event.shiftKey) {
341+
const firstNoteContent = containerElement?.querySelector<HTMLDivElement>(
342+
`.${getNotesContainerClassName(focusedAriaRowindex)} .${NOTE_CONTENT_CLASS_NAME}`
343+
);
344+
345+
if (firstNoteContent) {
346+
firstNoteContent.focus();
347+
return;
348+
}
349+
}
350+
315351
const ariaColindex = isArrowRightOrArrowLeft(event)
316352
? getNewAriaColindex({
317353
focusedAriaColindex,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { mount } from 'enzyme';
8+
import React from 'react';
9+
10+
import { TooltipWithKeyboardShortcut } from '.';
11+
12+
const props = {
13+
content: <div>{'To pay respect'}</div>,
14+
shortcut: 'F',
15+
showShortcut: true,
16+
};
17+
18+
describe('TooltipWithKeyboardShortcut', () => {
19+
test('it renders the provided content', () => {
20+
const wrapper = mount(<TooltipWithKeyboardShortcut {...props} />);
21+
22+
expect(wrapper.find('[data-test-subj="content"]').text()).toBe('To pay respect');
23+
});
24+
25+
test('it renders the additionalScreenReaderOnlyContext', () => {
26+
const wrapper = mount(
27+
<TooltipWithKeyboardShortcut {...props} additionalScreenReaderOnlyContext={'field.name'} />
28+
);
29+
30+
expect(wrapper.find('[data-test-subj="additionalScreenReaderOnlyContext"]').text()).toBe(
31+
'field.name'
32+
);
33+
});
34+
35+
test('it renders the expected shortcut', () => {
36+
const wrapper = mount(<TooltipWithKeyboardShortcut {...props} />);
37+
38+
expect(wrapper.find('[data-test-subj="shortcut"]').first().text()).toBe('Press\u00a0F');
39+
});
40+
});

x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import { EuiScreenReaderOnly, EuiText } from '@elastic/eui';
7+
import { EuiText, EuiScreenReaderOnly } from '@elastic/eui';
88
import React from 'react';
99

1010
import * as i18n from './translations';
@@ -23,14 +23,14 @@ const TooltipWithKeyboardShortcutComponent = ({
2323
showShortcut,
2424
}: Props) => (
2525
<>
26-
<div>{content}</div>
26+
<div data-test-subj="content">{content}</div>
2727
{additionalScreenReaderOnlyContext !== '' && (
28-
<EuiScreenReaderOnly>
28+
<EuiScreenReaderOnly data-test-subj="additionalScreenReaderOnlyContext">
2929
<p>{additionalScreenReaderOnlyContext}</p>
3030
</EuiScreenReaderOnly>
3131
)}
3232
{showShortcut && (
33-
<EuiText color="subdued" size="s" textAlign="center">
33+
<EuiText color="subdued" data-test-subj="shortcut" size="s" textAlign="center">
3434
<span>{i18n.PRESS}</span>
3535
{'\u00a0'}
3636
<span className="euiBadge euiBadge--hollow">{shortcut}</span>

x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import '../../mock/match_media';
1414
import { useKibana } from '../../lib/kibana';
1515
import { TestProviders } from '../../mock';
1616
import { FilterManager } from '../../../../../../../src/plugins/data/public';
17-
import { useAddToTimeline } from '../../hooks/use_add_to_timeline';
1817
import { useSourcererScope } from '../../containers/sourcerer';
1918
import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content';
2019
import {
@@ -41,8 +40,14 @@ jest.mock('uuid', () => {
4140
v4: jest.fn(() => 'uuid.v4()'),
4241
};
4342
});
44-
45-
jest.mock('../../hooks/use_add_to_timeline');
43+
const mockStartDragToTimeline = jest.fn();
44+
jest.mock('../../hooks/use_add_to_timeline', () => {
45+
const original = jest.requireActual('../../hooks/use_add_to_timeline');
46+
return {
47+
...original,
48+
useAddToTimeline: () => ({ startDragToTimeline: mockStartDragToTimeline }),
49+
};
50+
});
4651
const mockAddFilters = jest.fn();
4752
const mockGetTimelineFilterManager = jest.fn().mockReturnValue({
4853
addFilters: mockAddFilters,
@@ -78,8 +83,7 @@ const defaultProps = {
7883

7984
describe('DraggableWrapperHoverContent', () => {
8085
beforeAll(() => {
81-
// our mock implementation of the useAddToTimeline hook returns a mock startDragToTimeline function:
82-
(useAddToTimeline as jest.Mock).mockReturnValue({ startDragToTimeline: jest.fn() });
86+
mockStartDragToTimeline.mockReset();
8387
(useSourcererScope as jest.Mock).mockReturnValue({
8488
browserFields: mockBrowserFields,
8589
selectedPatterns: [],
@@ -376,7 +380,7 @@ describe('DraggableWrapperHoverContent', () => {
376380
});
377381
});
378382

379-
test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', () => {
383+
test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', async () => {
380384
const wrapper = mount(
381385
<TestProviders>
382386
<DraggableWrapperHoverContent
@@ -389,25 +393,17 @@ describe('DraggableWrapperHoverContent', () => {
389393
</TestProviders>
390394
);
391395

392-
// The following "startDragToTimeline" function returned by our mock
393-
// useAddToTimeline hook is called when the user clicks the
394-
// Add to timeline investigation action:
395-
const { startDragToTimeline } = useAddToTimeline({
396-
draggableId,
397-
fieldName: aggregatableStringField,
398-
});
399-
400396
wrapper.find('[data-test-subj="add-to-timeline"]').first().simulate('click');
401-
wrapper.update();
402397

403-
waitFor(() => {
404-
expect(startDragToTimeline).toHaveBeenCalled();
398+
await waitFor(() => {
399+
wrapper.update();
400+
expect(mockStartDragToTimeline).toHaveBeenCalled();
405401
});
406402
});
407403
});
408404

409405
describe('Top N', () => {
410-
test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, async () => {
406+
test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, () => {
411407
const aggregatableStringField = 'cloud.account.id';
412408
const wrapper = mount(
413409
<TestProviders>
@@ -425,7 +421,7 @@ describe('DraggableWrapperHoverContent', () => {
425421
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true);
426422
});
427423

428-
test(`it renders the 'Show top field' button when showTopN is false and a allowlisted signal field is provided`, async () => {
424+
test(`it renders the 'Show top field' button when showTopN is false and a allowlisted signal field is provided`, () => {
429425
const allowlistedField = 'signal.rule.name';
430426
const wrapper = mount(
431427
<TestProviders>
@@ -443,7 +439,7 @@ describe('DraggableWrapperHoverContent', () => {
443439
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true);
444440
});
445441

446-
test(`it does NOT render the 'Show top field' button when showTopN is false and a field not known to BrowserFields is provided`, async () => {
442+
test(`it does NOT render the 'Show top field' button when showTopN is false and a field not known to BrowserFields is provided`, () => {
447443
const notKnownToBrowserFields = 'unknown.field';
448444
const wrapper = mount(
449445
<TestProviders>
@@ -461,7 +457,7 @@ describe('DraggableWrapperHoverContent', () => {
461457
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false);
462458
});
463459

464-
test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => {
460+
test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, async () => {
465461
const allowlistedField = 'signal.rule.name';
466462
const wrapper = mount(
467463
<TestProviders>
@@ -476,12 +472,12 @@ describe('DraggableWrapperHoverContent', () => {
476472
);
477473
const button = wrapper.find(`[data-test-subj="show-top-field"]`).first();
478474
button.simulate('mouseenter');
479-
waitFor(() => {
475+
await waitFor(() => {
480476
expect(goGetTimelineId).toHaveBeenCalledWith(true);
481477
});
482478
});
483479

484-
test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => {
480+
test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, () => {
485481
const allowlistedField = 'signal.rule.name';
486482
const wrapper = mount(
487483
<TestProviders>
@@ -502,7 +498,7 @@ describe('DraggableWrapperHoverContent', () => {
502498
expect(toggleTopN).toBeCalled();
503499
});
504500

505-
test(`it does NOT render the Top N histogram when when showTopN is false`, async () => {
501+
test(`it does NOT render the Top N histogram when when showTopN is false`, () => {
506502
const allowlistedField = 'signal.rule.name';
507503
const wrapper = mount(
508504
<TestProviders>
@@ -522,7 +518,7 @@ describe('DraggableWrapperHoverContent', () => {
522518
);
523519
});
524520

525-
test(`it does NOT render the 'Show top field' button when showTopN is true`, async () => {
521+
test(`it does NOT render the 'Show top field' button when showTopN is true`, () => {
526522
const allowlistedField = 'signal.rule.name';
527523
const wrapper = mount(
528524
<TestProviders>
@@ -541,7 +537,7 @@ describe('DraggableWrapperHoverContent', () => {
541537
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false);
542538
});
543539

544-
test(`it renders the Top N histogram when when showTopN is true`, async () => {
540+
test(`it renders the Top N histogram when when showTopN is true`, () => {
545541
const allowlistedField = 'signal.rule.name';
546542
const wrapper = mount(
547543
<TestProviders>

x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
324324
color="text"
325325
data-test-subj="add-to-timeline"
326326
iconType="timeline"
327+
onClick={handleStartDragToTimeline}
327328
/>
328329
</EuiToolTip>
329330
)}

x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import { BrowserFields, DocValueFields } from '../../containers/source';
1616
import { useTimelineEvents } from '../../../timelines/containers';
1717
import { timelineActions } from '../../../timelines/store/timeline';
1818
import { useKibana } from '../../lib/kibana';
19-
import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model';
19+
import {
20+
ColumnHeaderOptions,
21+
KqlMode,
22+
TimelineTabs,
23+
} from '../../../timelines/store/timeline/model';
2024
import { HeaderSection } from '../header_section';
2125
import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers';
2226
import { Sort } from '../../../timelines/components/timeline/body/sort';
@@ -334,6 +338,7 @@ const EventsViewerComponent: React.FC<Props> = ({
334338
onRuleChange={onRuleChange}
335339
refetch={refetch}
336340
sort={sort}
341+
tabType={TimelineTabs.query}
337342
totalPages={calculateTotalPages({
338343
itemsCount: totalCountMinusDeleted,
339344
itemsPerPage,

0 commit comments

Comments
 (0)