[Security Solutions] fix timeline tabs + layout (#86581)

* fix timeline tabs + fix screenreader

* review

* fix jest tests
This commit is contained in:
Xavier Mouligneau 2020-12-19 18:38:46 -05:00 committed by GitHub
parent e8b21bc6c1
commit 9fce3b2c88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 409 additions and 156 deletions

View file

@ -1,21 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { ariaIndexToArrayIndex, arrayIndexToAriaIndex } from './helpers';
describe('helpers', () => {
describe('ariaIndexToArrayIndex', () => {
it('returns the expected array index', () => {
expect(ariaIndexToArrayIndex(1)).toEqual(0);
});
});
describe('arrayIndexToAriaIndex', () => {
it('returns the expected aria index', () => {
expect(arrayIndexToAriaIndex(0)).toEqual(1);
});
});
});

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import React from 'react';
import {
ariaIndexToArrayIndex,
arrayIndexToAriaIndex,
getNotesContainerClassName,
getRowRendererClassName,
isArrowRight,
} from './helpers';
describe('helpers', () => {
describe('ariaIndexToArrayIndex', () => {
test('it returns the expected array index', () => {
expect(ariaIndexToArrayIndex(1)).toEqual(0);
});
});
describe('arrayIndexToAriaIndex', () => {
test('it returns the expected aria index', () => {
expect(arrayIndexToAriaIndex(0)).toEqual(1);
});
});
describe('isArrowRight', () => {
test('it returns true if the right arrow key was pressed', () => {
let result = false;
const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
result = isArrowRight(keyboardEvent);
};
const wrapper = mount(<div onKeyDown={onKeyDown} />);
wrapper.find('div').simulate('keydown', { key: 'ArrowRight' });
wrapper.update();
expect(result).toBe(true);
});
test('it returns false if another key was pressed', () => {
let result = false;
const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
result = isArrowRight(keyboardEvent);
};
const wrapper = mount(<div onKeyDown={onKeyDown} />);
wrapper.find('div').simulate('keydown', { key: 'Enter' });
wrapper.update();
expect(result).toBe(false);
});
});
describe('getRowRendererClassName', () => {
test('it returns the expected class name', () => {
expect(getRowRendererClassName(2)).toBe('row-renderer-2');
});
});
describe('getNotesContainerClassName', () => {
test('it returns the expected class name', () => {
expect(getNotesContainerClassName(2)).toBe('notes-container-2');
});
});
});

View file

@ -5,6 +5,11 @@
*/
import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../drag_and_drop/helpers';
import {
NOTES_CONTAINER_CLASS_NAME,
NOTE_CONTENT_CLASS_NAME,
ROW_RENDERER_CLASS_NAME,
} from '../../../timelines/components/timeline/body/helpers';
import { HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME } from '../with_hover_actions';
/**
@ -63,6 +68,9 @@ export const isArrowDownOrArrowUp = (event: React.KeyboardEvent): boolean =>
export const isArrowKey = (event: React.KeyboardEvent): boolean =>
isArrowRightOrArrowLeft(event) || isArrowDownOrArrowUp(event);
/** Returns `true` if the right arrow key was pressed */
export const isArrowRight = (event: React.KeyboardEvent): boolean => event.key === 'ArrowRight';
/** Returns `true` if the escape key was pressed */
export const isEscape = (event: React.KeyboardEvent): boolean => event.key === 'Escape';
@ -284,6 +292,12 @@ export type OnColumnFocused = ({
newFocusedColumnAriaColindex: number | null;
}) => void;
export const getRowRendererClassName = (ariaRowindex: number) =>
`${ROW_RENDERER_CLASS_NAME}-${ariaRowindex}`;
export const getNotesContainerClassName = (ariaRowindex: number) =>
`${NOTES_CONTAINER_CLASS_NAME}-${ariaRowindex}`;
/**
* This function implements arrow key support for the `onKeyDownFocusHandler`.
*
@ -312,6 +326,28 @@ export const onArrowKeyDown = ({
onColumnFocused?: OnColumnFocused;
rowindexAttribute: string;
}) => {
if (isArrowDown(event) && event.shiftKey) {
const firstRowRendererDraggable = containerElement?.querySelector<HTMLDivElement>(
`.${getRowRendererClassName(focusedAriaRowindex)} .${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}`
);
if (firstRowRendererDraggable) {
firstRowRendererDraggable.focus();
return;
}
}
if (isArrowRight(event) && event.shiftKey) {
const firstNoteContent = containerElement?.querySelector<HTMLDivElement>(
`.${getNotesContainerClassName(focusedAriaRowindex)} .${NOTE_CONTENT_CLASS_NAME}`
);
if (firstNoteContent) {
firstNoteContent.focus();
return;
}
}
const ariaColindex = isArrowRightOrArrowLeft(event)
? getNewAriaColindex({
focusedAriaColindex,

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import React from 'react';
import { TooltipWithKeyboardShortcut } from '.';
const props = {
content: <div>{'To pay respect'}</div>,
shortcut: 'F',
showShortcut: true,
};
describe('TooltipWithKeyboardShortcut', () => {
test('it renders the provided content', () => {
const wrapper = mount(<TooltipWithKeyboardShortcut {...props} />);
expect(wrapper.find('[data-test-subj="content"]').text()).toBe('To pay respect');
});
test('it renders the additionalScreenReaderOnlyContext', () => {
const wrapper = mount(
<TooltipWithKeyboardShortcut {...props} additionalScreenReaderOnlyContext={'field.name'} />
);
expect(wrapper.find('[data-test-subj="additionalScreenReaderOnlyContext"]').text()).toBe(
'field.name'
);
});
test('it renders the expected shortcut', () => {
const wrapper = mount(<TooltipWithKeyboardShortcut {...props} />);
expect(wrapper.find('[data-test-subj="shortcut"]').first().text()).toBe('Press\u00a0F');
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiScreenReaderOnly, EuiText } from '@elastic/eui';
import { EuiText, EuiScreenReaderOnly } from '@elastic/eui';
import React from 'react';
import * as i18n from './translations';
@ -23,14 +23,14 @@ const TooltipWithKeyboardShortcutComponent = ({
showShortcut,
}: Props) => (
<>
<div>{content}</div>
<div data-test-subj="content">{content}</div>
{additionalScreenReaderOnlyContext !== '' && (
<EuiScreenReaderOnly>
<EuiScreenReaderOnly data-test-subj="additionalScreenReaderOnlyContext">
<p>{additionalScreenReaderOnlyContext}</p>
</EuiScreenReaderOnly>
)}
{showShortcut && (
<EuiText color="subdued" size="s" textAlign="center">
<EuiText color="subdued" data-test-subj="shortcut" size="s" textAlign="center">
<span>{i18n.PRESS}</span>
{'\u00a0'}
<span className="euiBadge euiBadge--hollow">{shortcut}</span>

View file

@ -14,7 +14,6 @@ import '../../mock/match_media';
import { useKibana } from '../../lib/kibana';
import { TestProviders } from '../../mock';
import { FilterManager } from '../../../../../../../src/plugins/data/public';
import { useAddToTimeline } from '../../hooks/use_add_to_timeline';
import { useSourcererScope } from '../../containers/sourcerer';
import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content';
import {
@ -41,8 +40,14 @@ jest.mock('uuid', () => {
v4: jest.fn(() => 'uuid.v4()'),
};
});
jest.mock('../../hooks/use_add_to_timeline');
const mockStartDragToTimeline = jest.fn();
jest.mock('../../hooks/use_add_to_timeline', () => {
const original = jest.requireActual('../../hooks/use_add_to_timeline');
return {
...original,
useAddToTimeline: () => ({ startDragToTimeline: mockStartDragToTimeline }),
};
});
const mockAddFilters = jest.fn();
const mockGetTimelineFilterManager = jest.fn().mockReturnValue({
addFilters: mockAddFilters,
@ -78,8 +83,7 @@ const defaultProps = {
describe('DraggableWrapperHoverContent', () => {
beforeAll(() => {
// our mock implementation of the useAddToTimeline hook returns a mock startDragToTimeline function:
(useAddToTimeline as jest.Mock).mockReturnValue({ startDragToTimeline: jest.fn() });
mockStartDragToTimeline.mockReset();
(useSourcererScope as jest.Mock).mockReturnValue({
browserFields: mockBrowserFields,
selectedPatterns: [],
@ -376,7 +380,7 @@ describe('DraggableWrapperHoverContent', () => {
});
});
test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', () => {
test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', async () => {
const wrapper = mount(
<TestProviders>
<DraggableWrapperHoverContent
@ -389,25 +393,17 @@ describe('DraggableWrapperHoverContent', () => {
</TestProviders>
);
// The following "startDragToTimeline" function returned by our mock
// useAddToTimeline hook is called when the user clicks the
// Add to timeline investigation action:
const { startDragToTimeline } = useAddToTimeline({
draggableId,
fieldName: aggregatableStringField,
});
wrapper.find('[data-test-subj="add-to-timeline"]').first().simulate('click');
wrapper.update();
waitFor(() => {
expect(startDragToTimeline).toHaveBeenCalled();
await waitFor(() => {
wrapper.update();
expect(mockStartDragToTimeline).toHaveBeenCalled();
});
});
});
describe('Top N', () => {
test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, async () => {
test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, () => {
const aggregatableStringField = 'cloud.account.id';
const wrapper = mount(
<TestProviders>
@ -425,7 +421,7 @@ describe('DraggableWrapperHoverContent', () => {
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true);
});
test(`it renders the 'Show top field' button when showTopN is false and a allowlisted signal field is provided`, async () => {
test(`it renders the 'Show top field' button when showTopN is false and a allowlisted signal field is provided`, () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>
@ -443,7 +439,7 @@ describe('DraggableWrapperHoverContent', () => {
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true);
});
test(`it does NOT render the 'Show top field' button when showTopN is false and a field not known to BrowserFields is provided`, async () => {
test(`it does NOT render the 'Show top field' button when showTopN is false and a field not known to BrowserFields is provided`, () => {
const notKnownToBrowserFields = 'unknown.field';
const wrapper = mount(
<TestProviders>
@ -461,7 +457,7 @@ describe('DraggableWrapperHoverContent', () => {
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false);
});
test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => {
test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, async () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>
@ -476,12 +472,12 @@ describe('DraggableWrapperHoverContent', () => {
);
const button = wrapper.find(`[data-test-subj="show-top-field"]`).first();
button.simulate('mouseenter');
waitFor(() => {
await waitFor(() => {
expect(goGetTimelineId).toHaveBeenCalledWith(true);
});
});
test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => {
test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>
@ -502,7 +498,7 @@ describe('DraggableWrapperHoverContent', () => {
expect(toggleTopN).toBeCalled();
});
test(`it does NOT render the Top N histogram when when showTopN is false`, async () => {
test(`it does NOT render the Top N histogram when when showTopN is false`, () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>
@ -522,7 +518,7 @@ describe('DraggableWrapperHoverContent', () => {
);
});
test(`it does NOT render the 'Show top field' button when showTopN is true`, async () => {
test(`it does NOT render the 'Show top field' button when showTopN is true`, () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>
@ -541,7 +537,7 @@ describe('DraggableWrapperHoverContent', () => {
expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false);
});
test(`it renders the Top N histogram when when showTopN is true`, async () => {
test(`it renders the Top N histogram when when showTopN is true`, () => {
const allowlistedField = 'signal.rule.name';
const wrapper = mount(
<TestProviders>

View file

@ -324,6 +324,7 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
color="text"
data-test-subj="add-to-timeline"
iconType="timeline"
onClick={handleStartDragToTimeline}
/>
</EuiToolTip>
)}

View file

@ -16,7 +16,11 @@ import { BrowserFields, DocValueFields } from '../../containers/source';
import { useTimelineEvents } from '../../../timelines/containers';
import { timelineActions } from '../../../timelines/store/timeline';
import { useKibana } from '../../lib/kibana';
import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model';
import {
ColumnHeaderOptions,
KqlMode,
TimelineTabs,
} from '../../../timelines/store/timeline/model';
import { HeaderSection } from '../header_section';
import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers';
import { Sort } from '../../../timelines/components/timeline/body/sort';
@ -334,6 +338,7 @@ const EventsViewerComponent: React.FC<Props> = ({
onRuleChange={onRuleChange}
refetch={refetch}
sort={sort}
tabType={TimelineTabs.query}
totalPages={calculateTotalPages({
itemsCount: totalCountMinusDeleted,
itemsPerPage,

View file

@ -89,8 +89,14 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
to zero.
*/
.euiScreenReaderOnly {
height: 0px;
width: 0px;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
`;

View file

@ -11,6 +11,7 @@ import '../../../../common/mock/formatted_relative';
import { NoteCards } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
import { TestProviders } from '../../../../common/mock';
import { TimelineResultNote } from '../../open_timeline/types';
const getNotesByIds = () => ({
abc: {
@ -38,35 +39,42 @@ jest.mock('../../../../common/hooks/use_selector', () => ({
}));
describe('NoteCards', () => {
const noteIds = ['abc', 'def'];
const notes: TimelineResultNote[] = Object.entries(getNotesByIds()).map(
([_, { created, id, note, saveObjectId, user }]) => ({
saveObjectId,
note,
noteId: id,
updated: created.getTime(),
updatedBy: user,
})
);
const props = {
associateNote: jest.fn(),
ariaRowindex: 2,
getNotesByIds,
getNewNoteId: jest.fn(),
noteIds,
notes: [],
showAddNote: true,
status: TimelineStatus.active,
toggleShowAddNote: jest.fn(),
updateNote: jest.fn(),
};
test('it renders the notes column when noteIds are specified', () => {
test('it renders the notes column when notes are specified', () => {
const wrapper = mount(
<TestProviders>
<NoteCards {...props} />
<NoteCards {...props} notes={notes} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="notes"]').exists()).toEqual(true);
});
test('it does NOT render the notes column when noteIds are NOT specified', () => {
const testProps = { ...props, noteIds: [] };
test('it does NOT render the notes column when notes are NOT specified', () => {
const wrapper = mount(
<TestProviders>
<NoteCards {...testProps} />
<NoteCards {...props} />
</TestProviders>
);
@ -76,7 +84,7 @@ describe('NoteCards', () => {
test('renders note cards', () => {
const wrapper = mount(
<TestProviders>
<NoteCards {...props} />
<NoteCards {...props} notes={notes} />
</TestProviders>
);
@ -85,6 +93,18 @@ describe('NoteCards', () => {
);
});
test('renders the expected screenreader only text', () => {
const wrapper = mount(
<TestProviders>
<NoteCards {...props} notes={notes} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="screenReaderOnly"]').first().text()).toEqual(
'You are viewing notes for the event in row 2. Press the up arrow key when finished to return to the event.'
);
});
test('it shows controls for adding notes when showAddNote is true', () => {
const wrapper = mount(
<TestProviders>

View file

@ -5,11 +5,10 @@
*/
import { EuiFlexGroup, EuiPanel, EuiScreenReaderOnly } from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import { appSelectors } from '../../../../common/store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { getNotesContainerClassName } from '../../../../common/components/accessibility/helpers';
import { AddNote } from '../add_note';
import { AssociateNote } from '../helpers';
import { NotePreviews, NotePreviewsContainer } from '../../open_timeline/note_previews';
@ -44,16 +43,14 @@ NotesContainer.displayName = 'NotesContainer';
interface Props {
ariaRowindex: number;
associateNote: AssociateNote;
noteIds: string[];
notes: TimelineResultNote[];
showAddNote: boolean;
toggleShowAddNote: () => void;
}
/** A view for entering and reviewing notes */
export const NoteCards = React.memo<Props>(
({ ariaRowindex, associateNote, noteIds, showAddNote, toggleShowAddNote }) => {
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
const notesById = useDeepEqualSelector(getNotesByIds);
({ ariaRowindex, associateNote, notes, showAddNote, toggleShowAddNote }) => {
const [newNote, setNewNote] = useState('');
const associateNoteAndToggleShow = useCallback(
@ -64,23 +61,16 @@ export const NoteCards = React.memo<Props>(
[associateNote, toggleShowAddNote]
);
const notes: TimelineResultNote[] = useMemo(
() =>
appSelectors.getNotes(notesById, noteIds).map((note) => ({
savedObjectId: note.saveObjectId,
note: note.note,
noteId: note.id,
updated: (note.lastEdit ?? note.created).getTime(),
updatedBy: note.user,
})),
[notesById, noteIds]
);
return (
<NoteCardsCompContainer data-test-subj="note-cards" hasShadow={false} paddingSize="none">
{notes.length ? (
<NotePreviewsContainer data-test-subj="note-previews-container">
<NotesContainer data-test-subj="notes" direction="column" gutterSize="none">
<NotesContainer
className={getNotesContainerClassName(ariaRowindex)}
data-test-subj="notes"
direction="column"
gutterSize="none"
>
<EuiScreenReaderOnly data-test-subj="screenReaderOnly">
<p>{i18n.YOU_ARE_VIEWING_NOTES(ariaRowindex)}</p>
</EuiScreenReaderOnly>

View file

@ -5,7 +5,7 @@
*/
import { uniqBy } from 'lodash/fp';
import { EuiAvatar, EuiButtonIcon, EuiCommentList } from '@elastic/eui';
import { EuiAvatar, EuiButtonIcon, EuiCommentList, EuiScreenReaderOnly } from '@elastic/eui';
import { FormattedRelative } from '@kbn/i18n/react';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
@ -15,6 +15,7 @@ import { TimelineResultNote } from '../types';
import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value';
import { MarkdownRenderer } from '../../../../common/components/markdown_editor';
import { timelineActions } from '../../../store/timeline';
import { NOTE_CONTENT_CLASS_NAME } from '../../timeline/body/helpers';
import * as i18n from './translations';
export const NotePreviewsContainer = styled.section`
@ -89,7 +90,14 @@ export const NotePreviews = React.memo<NotePreviewsProps>(
) : (
getEmptyValue()
),
children: <MarkdownRenderer>{note.note ?? ''}</MarkdownRenderer>,
children: (
<div className={NOTE_CONTENT_CLASS_NAME} tabIndex={0}>
<EuiScreenReaderOnly data-test-subj="screenReaderOnlyUserAddedANote">
<p>{i18n.USER_ADDED_A_NOTE(note.updatedBy ?? i18n.AN_UNKNOWN_USER)}</p>
</EuiScreenReaderOnly>
<MarkdownRenderer>{note.note ?? ''}</MarkdownRenderer>
</div>
),
actions:
eventId && timelineId ? (
<ToggleEventDetailsButton eventId={eventId} timelineId={timelineId} />

View file

@ -12,3 +12,16 @@ export const TOGGLE_EXPAND_EVENT_DETAILS = i18n.translate(
defaultMessage: 'Expand event details',
}
);
export const USER_ADDED_A_NOTE = (user: string) =>
i18n.translate('xpack.securitySolution.timeline.userAddedANoteScreenReaderOnly', {
values: { user },
defaultMessage: '{user} added a note',
});
export const AN_UNKNOWN_USER = i18n.translate(
'xpack.securitySolution.timeline.anUnknownUserScreenReaderOnly',
{
defaultMessage: 'an unknown user',
}
);

View file

@ -44,6 +44,7 @@ exports[`Columns it renders the expected columns 1`] = `
truncate={true}
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={3}
@ -74,6 +75,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="Access"
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={4}
@ -104,6 +106,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="Action"
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={5}
@ -134,6 +137,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="apache"
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={6}
@ -164,6 +168,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="192.168.0.1"
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={7}
@ -194,6 +199,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="192.168.0.3"
/>
</styled.div>
0
</styled.div>
<styled.div
$ariaColumnIndex={8}
@ -224,6 +230,7 @@ exports[`Columns it renders the expected columns 1`] = `
value="john.dee"
/>
</styled.div>
0
</styled.div>
</styled.div>
`;

View file

@ -26,6 +26,8 @@ describe('Columns', () => {
columnRenderers={columnRenderers}
data={mockTimelineData[0].data}
ecsData={mockTimelineData[0].ecs}
hasRowRenderers={false}
notesCount={0}
timelineId="test"
/>
);

View file

@ -21,12 +21,14 @@ import * as i18n from './translations';
interface Props {
_id: string;
activeTab?: TimelineTabs;
ariaRowindex: number;
columnHeaders: ColumnHeaderOptions[];
columnRenderers: ColumnRenderer[];
data: TimelineNonEcsData[];
ecsData: Ecs;
hasRowRenderers: boolean;
notesCount: number;
tabType?: TimelineTabs;
timelineId: string;
}
@ -74,12 +76,23 @@ export const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
};
export const DataDrivenColumns = React.memo<Props>(
({ _id, activeTab, ariaRowindex, columnHeaders, columnRenderers, data, ecsData, timelineId }) => (
({
_id,
ariaRowindex,
columnHeaders,
columnRenderers,
data,
ecsData,
hasRowRenderers,
notesCount,
tabType,
timelineId,
}) => (
<EventsTdGroupData data-test-subj="data-driven-columns">
{columnHeaders.map((header, i) => (
<EventsTd
$ariaColumnIndex={i + ARIA_COLUMN_INDEX_OFFSET}
key={activeTab != null ? `${header.id}_${activeTab}` : `${header.id}`}
key={tabType != null ? `${header.id}_${tabType}` : `${header.id}`}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
@ -95,7 +108,7 @@ export const DataDrivenColumns = React.memo<Props>(
eventId: _id,
field: header,
linkValues: getOr([], header.linkField ?? '', ecsData),
timelineId: activeTab != null ? `${timelineId}-${activeTab}` : timelineId,
timelineId: tabType != null ? `${timelineId}-${tabType}` : timelineId,
truncate: true,
values: getMappedNonEcsValue({
data,
@ -104,6 +117,17 @@ export const DataDrivenColumns = React.memo<Props>(
})}
</>
</EventsTdContent>
{hasRowRenderers && (
<EuiScreenReaderOnly data-test-subj="hasRowRendererScreenReaderOnly">
<p>{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
)}
{notesCount && (
<EuiScreenReaderOnly data-test-subj="hasNotesScreenReaderOnly">
<p>{i18n.EVENT_HAS_NOTES({ row: ariaRowindex, notesCount })}</p>
</EuiScreenReaderOnly>
)}
</EventsTd>
))}
</EventsTdGroupData>

View file

@ -11,3 +11,17 @@ export const YOU_ARE_IN_A_TABLE_CELL = ({ column, row }: { column: number; row:
values: { column, row },
defaultMessage: 'You are in a table cell. row: {row}, column: {column}',
});
export const EVENT_HAS_AN_EVENT_RENDERER = (row: number) =>
i18n.translate('xpack.securitySolution.timeline.eventHasEventRendererScreenReaderOnly', {
values: { row },
defaultMessage:
'The event in row {row} has an event renderer. Press shift + down arrow to focus it.',
});
export const EVENT_HAS_NOTES = ({ notesCount, row }: { notesCount: number; row: number }) =>
i18n.translate('xpack.securitySolution.timeline.eventHasNotesScreenReaderOnly', {
values: { notesCount, row },
defaultMessage:
'The event in row {row} has {notesCount, plural, =1 {a note} other {{notesCount} notes}}. Press shift + right arrow to focus notes.',
});

View file

@ -36,8 +36,10 @@ describe('EventColumnView', () => {
},
eventIdToNoteIds: {},
expanded: false,
hasRowRenderers: false,
loading: false,
loadingEventIds: [],
notesCount: 0,
onEventToggled: jest.fn(),
onPinEvent: jest.fn(),
onRowSelected: jest.fn(),

View file

@ -35,7 +35,6 @@ import * as i18n from '../translations';
interface Props {
id: string;
actionsColumnWidth: number;
activeTab?: TimelineTabs;
ariaRowindex: number;
columnHeaders: ColumnHeaderOptions[];
columnRenderers: ColumnRenderer[];
@ -46,15 +45,18 @@ interface Props {
isEventPinned: boolean;
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
notesCount: number;
onEventToggled: () => void;
onPinEvent: OnPinEvent;
onRowSelected: OnRowSelected;
onUnPinEvent: OnUnPinEvent;
refetch: inputsModel.Refetch;
onRuleChange?: () => void;
hasRowRenderers: boolean;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
showNotes: boolean;
tabType?: TimelineTabs;
timelineId: string;
toggleShowNotes: () => void;
}
@ -65,7 +67,6 @@ export const EventColumnView = React.memo<Props>(
({
id,
actionsColumnWidth,
activeTab,
ariaRowindex,
columnHeaders,
columnRenderers,
@ -76,15 +77,18 @@ export const EventColumnView = React.memo<Props>(
isEventPinned = false,
isEventViewer = false,
loadingEventIds,
notesCount,
onEventToggled,
onPinEvent,
onRowSelected,
onUnPinEvent,
refetch,
hasRowRenderers,
onRuleChange,
selectedEventIds,
showCheckboxes,
showNotes,
tabType,
timelineId,
toggleShowNotes,
}) => {
@ -225,12 +229,14 @@ export const EventColumnView = React.memo<Props>(
<DataDrivenColumns
_id={id}
activeTab={activeTab}
ariaRowindex={ariaRowindex}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={data}
ecsData={ecsData}
hasRowRenderers={hasRowRenderers}
notesCount={notesCount}
tabType={tabType}
timelineId={timelineId}
/>
</EventsTrData>

View file

@ -24,7 +24,6 @@ import { eventIsPinned } from '../helpers';
const ARIA_ROW_INDEX_OFFSET = 2;
interface Props {
activeTab?: TimelineTabs;
actionsColumnWidth: number;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
@ -43,11 +42,11 @@ interface Props {
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
tabType?: TimelineTabs;
}
const EventsComponent: React.FC<Props> = ({
actionsColumnWidth,
activeTab,
browserFields,
columnHeaders,
columnRenderers,
@ -65,11 +64,11 @@ const EventsComponent: React.FC<Props> = ({
rowRenderers,
selectedEventIds,
showCheckboxes,
tabType,
}) => (
<EventsTbody data-test-subj="events">
{data.map((event, i) => (
<StatefulEvent
activeTab={activeTab}
actionsColumnWidth={actionsColumnWidth}
ariaRowindex={i + ARIA_ROW_INDEX_OFFSET}
browserFields={browserFields}
@ -80,7 +79,7 @@ const EventsComponent: React.FC<Props> = ({
eventIdToNoteIds={eventIdToNoteIds}
isEventPinned={eventIsPinned({ eventId: event._id, pinnedEventIds })}
isEventViewer={isEventViewer}
key={`${id}_${activeTab}_${event._id}_${event._index}`}
key={`${id}_${tabType}_${event._id}_${event._index}`}
lastFocusedAriaColindex={lastFocusedAriaColindex}
loadingEventIds={loadingEventIds}
onRowSelected={onRowSelected}
@ -89,6 +88,7 @@ const EventsComponent: React.FC<Props> = ({
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
tabType={tabType}
timelineId={id}
/>
))}

View file

@ -19,22 +19,22 @@ import { OnPinEvent, OnRowSelected } from '../../events';
import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers';
import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles';
import { ColumnRenderer } from '../renderers/column_renderer';
import { RowRenderer } from '../renderers/row_renderer';
import { isEventBuildingBlockType, getEventType } from '../helpers';
import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view';
import { inputsModel } from '../../../../../common/store';
import { appSelectors, inputsModel } from '../../../../../common/store';
import { timelineActions, timelineSelectors } from '../../../../store/timeline';
import { activeTimeline } from '../../../../containers/active_timeline_context';
import { TimelineResultNote } from '../../../open_timeline/types';
import { getRowRenderer } from '../renderers/get_row_renderer';
import { StatefulRowRenderer } from './stateful_row_renderer';
import { NOTES_BUTTON_CLASS_NAME } from '../../properties/helpers';
import { timelineDefaults } from '../../../../store/timeline/defaults';
interface Props {
actionsColumnWidth: number;
activeTab?: TimelineTabs;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
@ -52,6 +52,7 @@ interface Props {
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
tabType?: TimelineTabs;
timelineId: string;
}
@ -66,7 +67,6 @@ EventsTrSupplementContainerWrapper.displayName = 'EventsTrSupplementContainerWra
const StatefulEventComponent: React.FC<Props> = ({
actionsColumnWidth,
activeTab,
browserFields,
containerRef,
columnHeaders,
@ -84,6 +84,7 @@ const StatefulEventComponent: React.FC<Props> = ({
ariaRowindex,
selectedEventIds,
showCheckboxes,
tabType,
timelineId,
}) => {
const trGroupRef = useRef<HTMLDivElement | null>(null);
@ -93,12 +94,31 @@ const StatefulEventComponent: React.FC<Props> = ({
const expandedEvent = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
);
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
const notesById = useDeepEqualSelector(getNotesByIds);
const noteIds: string[] = eventIdToNoteIds[event._id] || emptyNotes;
const isExpanded = useMemo(() => expandedEvent && expandedEvent.eventId === event._id, [
event._id,
expandedEvent,
]);
const notes: TimelineResultNote[] = useMemo(
() =>
appSelectors.getNotes(notesById, noteIds).map((note) => ({
savedObjectId: note.saveObjectId,
note: note.note,
noteId: note.id,
updated: (note.lastEdit ?? note.created).getTime(),
updatedBy: note.user,
})),
[notesById, noteIds]
);
const hasRowRenderers: boolean = useMemo(() => getRowRenderer(event.ecs, rowRenderers) != null, [
event.ecs,
rowRenderers,
]);
const onToggleShowNotes = useCallback(() => {
const eventId = event._id;
@ -195,7 +215,6 @@ const StatefulEventComponent: React.FC<Props> = ({
<EventColumnView
id={event._id}
actionsColumnWidth={actionsColumnWidth}
activeTab={activeTab}
ariaRowindex={ariaRowindex}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
@ -203,9 +222,11 @@ const StatefulEventComponent: React.FC<Props> = ({
ecsData={event.ecs}
eventIdToNoteIds={eventIdToNoteIds}
expanded={isExpanded}
hasRowRenderers={hasRowRenderers}
isEventPinned={isEventPinned}
isEventViewer={isEventViewer}
loadingEventIds={loadingEventIds}
notesCount={notes.length}
onEventToggled={handleOnEventToggled}
onPinEvent={onPinEvent}
onRowSelected={onRowSelected}
@ -215,6 +236,7 @@ const StatefulEventComponent: React.FC<Props> = ({
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
showNotes={!!showNotes[event._id]}
tabType={tabType}
timelineId={timelineId}
toggleShowNotes={onToggleShowNotes}
/>
@ -228,7 +250,7 @@ const StatefulEventComponent: React.FC<Props> = ({
ariaRowindex={ariaRowindex}
associateNote={associateNote}
data-test-subj="note-cards"
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
notes={notes}
showAddNote={!!showNotes[event._id]}
toggleShowAddNote={onToggleShowNotes}
/>

View file

@ -12,6 +12,7 @@ import { BrowserFields } from '../../../../../../common/containers/source';
import {
ARIA_COLINDEX_ATTRIBUTE,
ARIA_ROWINDEX_ATTRIBUTE,
getRowRendererClassName,
} from '../../../../../../common/components/accessibility/helpers';
import { TimelineItem } from '../../../../../../../common/search_strategy/timeline';
import { getRowRenderer } from '../../renderers/get_row_renderer';
@ -59,28 +60,44 @@ export const StatefulRowRenderer = ({
rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE,
});
const rowRenderer = useMemo(() => getRowRenderer(event.ecs, rowRenderers), [
event.ecs,
rowRenderers,
]);
const content = useMemo(
() => (
<EuiFocusTrap clickOutsideDisables={true} disabled={focusOwnership !== 'owned'}>
<EuiScreenReaderOnly data-test-subj="eventRendererScreenReaderOnly">
<p>{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
<div onKeyDown={onKeyDown}>
{getRowRenderer(event.ecs, rowRenderers).renderRow({
browserFields,
data: event.ecs,
timelineId,
})}
() =>
rowRenderer && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div className={getRowRendererClassName(ariaRowindex)} role="dialog" onFocus={onFocus}>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<EuiFocusTrap clickOutsideDisables={true} disabled={focusOwnership !== 'owned'}>
<EuiScreenReaderOnly data-test-subj="eventRendererScreenReaderOnly">
<p>{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
<div onKeyDown={onKeyDown}>
{rowRenderer.renderRow({
browserFields,
data: event.ecs,
timelineId,
})}
</div>
</EuiFocusTrap>
</EuiOutsideClickDetector>
</div>
</EuiFocusTrap>
),
[ariaRowindex, browserFields, event.ecs, focusOwnership, onKeyDown, rowRenderers, timelineId]
),
[
ariaRowindex,
browserFields,
event.ecs,
focusOwnership,
onFocus,
onKeyDown,
onOutsideClick,
rowRenderer,
timelineId,
]
);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div role="dialog" onFocus={onFocus}>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>{content}</EuiOutsideClickDetector>
</div>
);
return content;
};

View file

@ -160,3 +160,9 @@ const InvestigateInResolverActionComponent: React.FC<InvestigateInResolverAction
InvestigateInResolverActionComponent.displayName = 'InvestigateInResolverActionComponent';
export const InvestigateInResolverAction = React.memo(InvestigateInResolverActionComponent);
export const ROW_RENDERER_CLASS_NAME = 'row-renderer';
export const NOTES_CONTAINER_CLASS_NAME = 'notes-container';
export const NOTE_CONTENT_CLASS_NAME = 'note-content';

View file

@ -78,7 +78,7 @@ describe('Body', () => {
setSelected: (jest.fn() as unknown) as StatefulBodyProps['setSelected'],
sort: mockSort,
showCheckboxes: false,
activeTab: TimelineTabs.query,
tabType: TimelineTabs.query,
totalPages: 1,
};

View file

@ -21,7 +21,7 @@ import { BrowserFields } from '../../../../common/containers/source';
import { TimelineItem } from '../../../../../common/search_strategy/timeline';
import { inputsModel, State } from '../../../../common/store';
import { useManageTimeline } from '../../manage_timeline';
import { ColumnHeaderOptions, TimelineModel } from '../../../store/timeline/model';
import { ColumnHeaderOptions, TimelineModel, TimelineTabs } from '../../../store/timeline/model';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { OnRowSelected, OnSelectAll } from '../events';
@ -43,6 +43,7 @@ interface OwnProps {
isEventViewer?: boolean;
sort: Sort[];
refetch: inputsModel.Refetch;
tabType: TimelineTabs;
totalPages: number;
onRuleChange?: () => void;
}
@ -60,7 +61,6 @@ export type StatefulBodyProps = OwnProps & PropsFromRedux;
export const BodyComponent = React.memo<StatefulBodyProps>(
({
activeTab,
activePage,
browserFields,
columnHeaders,
@ -79,6 +79,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
showCheckboxes,
refetch,
sort,
tabType,
totalPages,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -200,7 +201,6 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
<Events
containerRef={containerRef}
actionsColumnWidth={actionsColumnWidth}
activeTab={activeTab}
browserFields={browserFields}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
@ -217,6 +217,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
tabType={tabType}
/>
</EventsTable>
</TimelineBody>
@ -225,7 +226,6 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
);
},
(prevProps, nextProps) =>
prevProps.activeTab === nextProps.activeTab &&
deepEqual(prevProps.browserFields, nextProps.browserFields) &&
deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) &&
deepEqual(prevProps.data, nextProps.data) &&
@ -238,7 +238,8 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
prevProps.id === nextProps.id &&
prevProps.isEventViewer === nextProps.isEventViewer &&
prevProps.isSelectAllChecked === nextProps.isSelectAllChecked &&
prevProps.showCheckboxes === nextProps.showCheckboxes
prevProps.showCheckboxes === nextProps.showCheckboxes &&
prevProps.tabType === nextProps.tabType
);
BodyComponent.displayName = 'BodyComponent';
@ -253,7 +254,6 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state: State, { browserFields, id }: OwnProps) => {
const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults;
const {
activeTab,
columns,
eventIdToNoteIds,
excludedRowRendererIds,
@ -265,7 +265,6 @@ const makeMapStateToProps = () => {
} = timeline;
return {
activeTab: id === TimelineId.active ? activeTab : undefined,
columnHeaders: memoizedColumnHeaders(columns, browserFields),
eventIdToNoteIds,
excludedRowRendererIds,

View file

@ -48,7 +48,7 @@ describe('get_column_renderer', () => {
test('renders correctly against snapshot', () => {
const rowRenderer = getRowRenderer(nonSuricata, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: nonSuricata,
timelineId: 'test',
@ -60,7 +60,7 @@ describe('get_column_renderer', () => {
test('should render plain row data when it is a non suricata row', () => {
const rowRenderer = getRowRenderer(nonSuricata, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: nonSuricata,
timelineId: 'test',
@ -75,7 +75,7 @@ describe('get_column_renderer', () => {
test('should render a suricata row data when it is a suricata row', () => {
const rowRenderer = getRowRenderer(suricata, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: suricata,
timelineId: 'test',
@ -93,7 +93,7 @@ describe('get_column_renderer', () => {
test('should render a suricata row data if event.category is network_traffic', () => {
suricata.event = { ...suricata.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(suricata, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: suricata,
timelineId: 'test',
@ -111,7 +111,7 @@ describe('get_column_renderer', () => {
test('should render a zeek row data if event.category is network_traffic', () => {
zeek.event = { ...zeek.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(zeek, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: zeek,
timelineId: 'test',
@ -129,7 +129,7 @@ describe('get_column_renderer', () => {
test('should render a system row data if event.category is network_traffic', () => {
system.event = { ...system.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(system, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: system,
timelineId: 'test',
@ -147,7 +147,7 @@ describe('get_column_renderer', () => {
test('should render a auditd row data if event.category is network_traffic', () => {
auditd.event = { ...auditd.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(auditd, rowRenderers);
const row = rowRenderer.renderRow({
const row = rowRenderer?.renderRow({
browserFields: mockBrowserFields,
data: auditd,
timelineId: 'test',

View file

@ -7,15 +7,5 @@
import { Ecs } from '../../../../../../common/ecs';
import { RowRenderer } from './row_renderer';
const unhandledRowRenderer = (): never => {
throw new Error('Unhandled Row Renderer');
};
export const getRowRenderer = (ecs: Ecs, rowRenderers: RowRenderer[]): RowRenderer => {
const renderer = rowRenderers.find((rowRenderer) => rowRenderer.isInstance(ecs));
if (renderer == null) {
return unhandledRowRenderer();
} else {
return renderer;
}
};
export const getRowRenderer = (ecs: Ecs, rowRenderers: RowRenderer[]): RowRenderer | null =>
rowRenderers.find((rowRenderer) => rowRenderer.isInstance(ecs)) ?? null;

View file

@ -9,7 +9,6 @@ import { ColumnRenderer } from './column_renderer';
import { emptyColumnRenderer } from './empty_column_renderer';
import { netflowRowRenderer } from './netflow/netflow_row_renderer';
import { plainColumnRenderer } from './plain_column_renderer';
import { plainRowRenderer } from './plain_row_renderer';
import { RowRenderer } from './row_renderer';
import { suricataRowRenderer } from './suricata/suricata_row_renderer';
import { unknownColumnRenderer } from './unknown_column_renderer';
@ -29,7 +28,6 @@ export const rowRenderers: RowRenderer[] = [
suricataRowRenderer,
zeekRowRenderer,
netflowRowRenderer,
plainRowRenderer, // falls-back to the plain row renderer
];
export const columnRenderers: ColumnRenderer[] = [

View file

@ -23,7 +23,7 @@ import { EventDetailsWidthProvider } from '../../../../common/components/events_
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { TimelineModel } from '../../../store/timeline/model';
import { TimelineModel, TimelineTabs } from '../../../store/timeline/model';
import { EventDetails } from '../event_details';
import { ToggleExpandedEvent } from '../../../store/timeline/actions';
import { State } from '../../../../common/store';
@ -183,6 +183,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
id={timelineId}
refetch={refetch}
sort={sort}
tabType={TimelineTabs.pinned}
totalPages={calculateTotalPages({
itemsCount: totalCount,
itemsPerPage,

View file

@ -330,6 +330,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
id={timelineId}
refetch={refetch}
sort={sort}
tabType={TimelineTabs.query}
totalPages={calculateTotalPages({
itemsCount: totalCount,
itemsPerPage,

View file

@ -199,7 +199,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({ timelineId, graphEve
disabled={!graphEventId}
key={TimelineTabs.graph}
>
{i18n.GRAPH_TAB}
{i18n.ANALYZER_TAB}
</EuiTab>
<StyledEuiTab
data-test-subj={`timelineTabs-${TimelineTabs.notes}`}

View file

@ -13,10 +13,10 @@ export const QUERY_TAB = i18n.translate(
}
);
export const GRAPH_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.graphTabTimelineTitle',
export const ANALYZER_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.analyserTabTimelineTitle',
{
defaultMessage: 'Graph',
defaultMessage: 'Analyzer',
}
);