mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solutions] fix timeline tabs + layout (#86581)
* fix timeline tabs + fix screenreader * review * fix jest tests
This commit is contained in:
parent
e8b21bc6c1
commit
9fce3b2c88
32 changed files with 409 additions and 156 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -324,6 +324,7 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
|
|||
color="text"
|
||||
data-test-subj="add-to-timeline"
|
||||
iconType="timeline"
|
||||
onClick={handleStartDragToTimeline}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -26,6 +26,8 @@ describe('Columns', () => {
|
|||
columnRenderers={columnRenderers}
|
||||
data={mockTimelineData[0].data}
|
||||
ecsData={mockTimelineData[0].ecs}
|
||||
hasRowRenderers={false}
|
||||
notesCount={0}
|
||||
timelineId="test"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -330,6 +330,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
|
|||
id={timelineId}
|
||||
refetch={refetch}
|
||||
sort={sort}
|
||||
tabType={TimelineTabs.query}
|
||||
totalPages={calculateTotalPages({
|
||||
itemsCount: totalCount,
|
||||
itemsPerPage,
|
||||
|
|
|
@ -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}`}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue