[Security Solution] [Unified Timeline] Notes, Pinned events, and row actions in timeline (#181376)

## Summary 

This pr adds pinned events, notes, and row actions to the unified data
table within timeline. Uses the existing shared hooks and components
from timeline, with only a few casts to make everything work. As with
the other parts of the unified timeline, this is hidden behind the
feature flag 'unifiedComponentsInTimelineEnabled'.

![timeline_notes_pinned](6aa5d951-a98e-4a84-9fc5-8546db3e9167)

Correlation/EQL tab:
<img width="862" alt="image"
src="5b7facfc-e385-41a2-b14c-e36cf134fe00">

Improved header controls positioning:
<img width="1456" alt="image"
src="a87e39d3-3f53-4266-9a2d-5bc33a37cfdc">



### Checklist


- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Michael Olorunnisola <michael.olorunnisola@elastic.co>
This commit is contained in:
Kevin Qualters 2024-05-03 18:13:54 -04:00 committed by GitHub
parent ca181965ca
commit 1f04b5f174
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1044 additions and 249 deletions

View file

@ -371,6 +371,10 @@ export interface UnifiedDataTableProps {
* This data is sent directly to actions. * This data is sent directly to actions.
*/ */
cellActionsMetadata?: Record<string, unknown>; cellActionsMetadata?: Record<string, unknown>;
/**
* Optional extra props passed to the renderCellValue function/component.
*/
cellContext?: EuiDataGridProps['cellContext'];
} }
export const EuiDataGridMemoized = React.memo(EuiDataGrid); export const EuiDataGridMemoized = React.memo(EuiDataGrid);
@ -438,6 +442,7 @@ export const UnifiedDataTable = ({
customGridColumnsConfiguration, customGridColumnsConfiguration,
customControlColumnsConfiguration, customControlColumnsConfiguration,
enableComparisonMode, enableComparisonMode,
cellContext,
}: UnifiedDataTableProps) => { }: UnifiedDataTableProps) => {
const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } = const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } =
services; services;
@ -1055,6 +1060,7 @@ export const UnifiedDataTable = ({
renderCustomGridBody={renderCustomGridBody} renderCustomGridBody={renderCustomGridBody}
renderCustomToolbar={renderCustomToolbarFn} renderCustomToolbar={renderCustomToolbarFn}
trailingControlColumns={customTrailingControlColumn} trailingControlColumns={customTrailingControlColumn}
cellContext={cellContext}
/> />
)} )}
</div> </div>

View file

@ -5,10 +5,10 @@
* 2.0. * 2.0.
*/ */
import type { EuiDataGridCellValueElementProps, EuiDataGridColumn } from '@elastic/eui'; import type { EuiDataGridColumn, EuiDataGridProps } from '@elastic/eui';
import type { IFieldSubType } from '@kbn/es-query'; import type { IFieldSubType } from '@kbn/es-query';
import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public'; import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public';
import type { ComponentType, JSXElementConstructor } from 'react'; import type { ComponentType } from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { BrowserFields } from '@kbn/rule-registry-plugin/common'; import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
@ -66,16 +66,7 @@ export interface HeaderActionProps {
export type HeaderCellRender = ComponentType | ComponentType<HeaderActionProps>; export type HeaderCellRender = ComponentType | ComponentType<HeaderActionProps>;
type GenericActionRowCellRenderProps = Pick< export type RowCellRender = EuiDataGridProps['renderCellValue'];
EuiDataGridCellValueElementProps,
'rowIndex' | 'columnId'
>;
export type RowCellRender =
| JSXElementConstructor<GenericActionRowCellRenderProps>
| ((props: GenericActionRowCellRenderProps) => JSX.Element)
| JSXElementConstructor<ActionProps>
| ((props: ActionProps) => JSX.Element);
export interface ActionProps { export interface ActionProps {
action?: RowCellRender; action?: RowCellRender;

View file

@ -59,6 +59,7 @@ export interface HeaderActionProps {
onSelectAll: ({ isSelected }: { isSelected: boolean }) => void; onSelectAll: ({ isSelected }: { isSelected: boolean }) => void;
showEventsSelect: boolean; showEventsSelect: boolean;
showSelectAllCheckbox: boolean; showSelectAllCheckbox: boolean;
showFullScreenToggle?: boolean;
sort: SortColumnTable[]; sort: SortColumnTable[];
tabType: string; tabType: string;
timelineId: string; timelineId: string;
@ -69,7 +70,8 @@ export type HeaderCellRender = ComponentType | ComponentType<HeaderActionProps>;
type GenericActionRowCellRenderProps = Pick< type GenericActionRowCellRenderProps = Pick<
EuiDataGridCellValueElementProps, EuiDataGridCellValueElementProps,
'rowIndex' | 'columnId' 'rowIndex' | 'columnId'
>; > &
Partial<EuiDataGridCellValueElementProps>;
export type RowCellRender = export type RowCellRender =
| JSXElementConstructor<GenericActionRowCellRenderProps> | JSXElementConstructor<GenericActionRowCellRenderProps>
@ -114,7 +116,6 @@ interface AdditionalControlColumnProps {
checked: boolean; checked: boolean;
onRowSelected: OnRowSelected; onRowSelected: OnRowSelected;
eventId: string; eventId: string;
id: string;
columnId: string; columnId: string;
loadingEventIds: Readonly<string[]>; loadingEventIds: Readonly<string[]>;
onEventDetailsPanelOpened: () => void; onEventDetailsPanelOpened: () => void;

View file

@ -14,10 +14,10 @@ import { getDefaultControlColumn } from '../../../../timelines/components/timeli
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
jest.mock('../../../hooks/use_experimental_features', () => ({ jest.mock('../../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
})); }));
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
useIsExperimentalFeatureEnabledMock.mockReturnValue(true); useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
const mockDispatch = jest.fn(); const mockDispatch = jest.fn();
jest.mock('react-redux', () => { jest.mock('react-redux', () => {

View file

@ -156,6 +156,7 @@ describe('Actions', () => {
describe('Guided Onboarding Step', () => { describe('Guided Onboarding Step', () => {
const incrementStepMock = jest.fn(); const incrementStepMock = jest.fn();
beforeEach(() => { beforeEach(() => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
(useTourContext as jest.Mock).mockReturnValue({ (useTourContext as jest.Mock).mockReturnValue({
activeStep: 2, activeStep: 2,
incrementStep: incrementStepMock, incrementStep: incrementStepMock,

View file

@ -68,6 +68,9 @@ const ActionsComponent: React.FC<ActionProps> = ({
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
const emptyNotes: string[] = []; const emptyNotes: string[] = [];
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timelineType = useShallowEqualSelector( const timelineType = useShallowEqualSelector(
@ -224,6 +227,10 @@ const ActionsComponent: React.FC<ActionProps> = ({
} }
onEventDetailsPanelOpened(); onEventDetailsPanelOpened();
}, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]); }, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]);
const showExpandEvent = useMemo(
() => !unifiedComponentsInTimelineEnabled || isEventViewer || timelineId !== TimelineId.active,
[isEventViewer, timelineId, unifiedComponentsInTimelineEnabled]
);
return ( return (
<ActionsContainer> <ActionsContainer>
@ -244,35 +251,38 @@ const ActionsComponent: React.FC<ActionProps> = ({
</EventsTdContent> </EventsTdContent>
</div> </div>
)} )}
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
onClick={onExpandEvent}
step={AlertsCasesTourSteps.expandEvent}
tourId={SecurityStepId.alertsCases}
>
<div key="expand-event">
<EventsTdContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={i18n.VIEW_DETAILS}>
<EuiButtonIcon
aria-label={i18n.VIEW_DETAILS_FOR_ROW({ ariaRowindex, columnValues })}
data-test-subj="expand-event"
iconType="expand"
onClick={onExpandEvent}
size="s"
/>
</EuiToolTip>
</EventsTdContent>
</div>
</GuidedOnboardingTourStep>
<> <>
{timelineId !== TimelineId.active && ( {showExpandEvent && (
<InvestigateInTimelineAction <GuidedOnboardingTourStep
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE_FOR_ROW({ ariaRowindex, columnValues })} isTourAnchor={isTourAnchor}
key="investigate-in-timeline" onClick={onExpandEvent}
ecsRowData={ecsData} step={AlertsCasesTourSteps.expandEvent}
/> tourId={SecurityStepId.alertsCases}
>
<div key="expand-event">
<EventsTdContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={i18n.VIEW_DETAILS}>
<EuiButtonIcon
aria-label={i18n.VIEW_DETAILS_FOR_ROW({ ariaRowindex, columnValues })}
data-test-subj="expand-event"
iconType="expand"
onClick={onExpandEvent}
size="s"
/>
</EuiToolTip>
</EventsTdContent>
</div>
</GuidedOnboardingTourStep>
)} )}
<>
{timelineId !== TimelineId.active && (
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE_FOR_ROW({ ariaRowindex, columnValues })}
key="investigate-in-timeline"
ecsRowData={ecsData}
/>
)}
</>
{!isEventViewer && toggleShowNotes && ( {!isEventViewer && toggleShowNotes && (
<> <>
<AddEventNoteAction <AddEventNoteAction
@ -281,6 +291,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
showNotes={showNotes ?? false} showNotes={showNotes ?? false}
toggleShowNotes={toggleShowNotes} toggleShowNotes={toggleShowNotes}
timelineType={timelineType} timelineType={timelineType}
eventId={eventId}
/> />
<PinEventAction <PinEventAction
ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })} ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })}

View file

@ -17,6 +17,7 @@ interface AddEventNoteActionProps {
showNotes: boolean; showNotes: boolean;
timelineType: TimelineType; timelineType: TimelineType;
toggleShowNotes: () => void; toggleShowNotes: () => void;
eventId?: string;
} }
const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({ const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({
@ -24,6 +25,7 @@ const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({
showNotes, showNotes,
timelineType, timelineType,
toggleShowNotes, toggleShowNotes,
eventId,
}) => { }) => {
const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges();
@ -39,6 +41,7 @@ const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({
toolTip={ toolTip={
timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP
} }
eventId={eventId}
/> />
</ActionIconItem> </ActionIconItem>
); );

View file

@ -68,6 +68,7 @@ const defaultProps: HeaderActionProps = {
tabType: TimelineTabs.query, tabType: TimelineTabs.query,
timelineId, timelineId,
width: 10, width: 10,
fieldBrowserOptions: {},
}; };
describe('HeaderActions', () => { describe('HeaderActions', () => {

View file

@ -78,6 +78,7 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = memo(
onSelectAll, onSelectAll,
showEventsSelect, showEventsSelect,
showSelectAllCheckbox, showSelectAllCheckbox,
showFullScreenToggle = true,
sort, sort,
tabType, tabType,
timelineId, timelineId,
@ -222,17 +223,19 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = memo(
</EventsThContent> </EventsThContent>
</EventsTh> </EventsTh>
)} )}
<EventsTh role="button"> {fieldBrowserOptions && (
<FieldBrowserContainer> <EventsTh role="button">
{triggersActionsUi.getFieldBrowser({ <FieldBrowserContainer>
browserFields, {triggersActionsUi.getFieldBrowser({
columnIds: columnHeaders.map(({ id }) => id), browserFields,
onResetColumns, columnIds: columnHeaders.map(({ id }) => id),
onToggleColumn, onResetColumns,
options: fieldBrowserOptions, onToggleColumn,
})} options: fieldBrowserOptions,
</FieldBrowserContainer> })}
</EventsTh> </FieldBrowserContainer>
</EventsTh>
)}
<EventsTh role="button"> <EventsTh role="button">
<StatefulRowRenderersBrowser <StatefulRowRenderersBrowser
@ -240,33 +243,34 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = memo(
timelineId={timelineId} timelineId={timelineId}
/> />
</EventsTh> </EventsTh>
{showFullScreenToggle && (
<EventsTh role="button"> <EventsTh role="button">
<EventsThContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}> <EventsThContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>
<EuiToolTip content={fullScreen ? EXIT_FULL_SCREEN : i18n.FULL_SCREEN}> <EuiToolTip content={fullScreen ? EXIT_FULL_SCREEN : i18n.FULL_SCREEN}>
<EuiButtonIcon <EuiButtonIcon
aria-label={ aria-label={
isFullScreen({ isFullScreen({
globalFullScreen, globalFullScreen,
isActiveTimelines: isActiveTimeline(timelineId), isActiveTimelines: isActiveTimeline(timelineId),
timelineFullScreen, timelineFullScreen,
}) })
? EXIT_FULL_SCREEN ? EXIT_FULL_SCREEN
: i18n.FULL_SCREEN : i18n.FULL_SCREEN
} }
display={fullScreen ? 'fill' : 'empty'} display={fullScreen ? 'fill' : 'empty'}
color="primary" color="primary"
data-test-subj={ data-test-subj={
// a full screen button gets created for timeline and for the host page // a full screen button gets created for timeline and for the host page
// this sets the data-test-subj for each case so that tests can differentiate between them // this sets the data-test-subj for each case so that tests can differentiate between them
isActiveTimeline(timelineId) ? 'full-screen-active' : 'full-screen' isActiveTimeline(timelineId) ? 'full-screen-active' : 'full-screen'
} }
iconType="fullScreen" iconType="fullScreen"
onClick={toggleFullScreen} onClick={toggleFullScreen}
/> />
</EuiToolTip> </EuiToolTip>
</EventsThContent> </EventsThContent>
</EventsTh> </EventsTh>
)}
{tabType !== TimelineTabs.eql && ( {tabType !== TimelineTabs.eql && (
<EventsTh role="button" data-test-subj="timeline-sorting-fields"> <EventsTh role="button" data-test-subj="timeline-sorting-fields">
<EventsThContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}> <EventsThContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>

View file

@ -44,26 +44,45 @@ NotesContainer.displayName = 'NotesContainer';
interface Props { interface Props {
ariaRowindex: number; ariaRowindex: number;
associateNote: AssociateNote; associateNote: AssociateNote;
className?: string;
notes: TimelineResultNote[]; notes: TimelineResultNote[];
showAddNote: boolean; showAddNote: boolean;
toggleShowAddNote: () => void; toggleShowAddNote: (eventId?: string) => void;
eventId?: string;
} }
/** A view for entering and reviewing notes */ /** A view for entering and reviewing notes */
export const NoteCards = React.memo<Props>( export const NoteCards = React.memo<Props>(
({ ariaRowindex, associateNote, notes, showAddNote, toggleShowAddNote }) => { ({ ariaRowindex, associateNote, className, notes, showAddNote, toggleShowAddNote, eventId }) => {
const [newNote, setNewNote] = useState(''); const [newNote, setNewNote] = useState('');
const associateNoteAndToggleShow = useCallback( const associateNoteAndToggleShow = useCallback(
(noteId: string) => { (noteId: string) => {
associateNote(noteId); associateNote(noteId);
toggleShowAddNote(); if (eventId != null) {
toggleShowAddNote(eventId);
} else {
toggleShowAddNote();
}
}, },
[associateNote, toggleShowAddNote] [associateNote, toggleShowAddNote, eventId]
); );
const onCancelAddNote = useCallback(() => {
if (eventId != null) {
toggleShowAddNote(eventId);
} else {
toggleShowAddNote();
}
}, [eventId, toggleShowAddNote]);
return ( return (
<NoteCardsCompContainer data-test-subj="note-cards" hasShadow={false} paddingSize="none"> <NoteCardsCompContainer
className={className}
data-test-subj="note-cards"
hasShadow={false}
paddingSize="none"
>
{notes.length ? ( {notes.length ? (
<NotePreviewsContainer data-test-subj="note-previews-container"> <NotePreviewsContainer data-test-subj="note-previews-container">
<NotesContainer <NotesContainer
@ -85,7 +104,7 @@ export const NoteCards = React.memo<Props>(
<AddNote <AddNote
associateNote={associateNoteAndToggleShow} associateNote={associateNoteAndToggleShow}
newNote={newNote} newNote={newNote}
onCancelAddNote={toggleShowAddNote} onCancelAddNote={onCancelAddNote}
updateNewNote={setNewNote} updateNewNote={setNewNote}
/> />
</AddNoteContainer> </AddNoteContainer>

View file

@ -96,6 +96,11 @@ describe('ColumnHeaders', () => {
}); });
test('it renders the field browser', () => { test('it renders the field browser', () => {
const mockCloseEditor = jest.fn();
mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => {
editorActionsRef.current = { closeEditor: mockCloseEditor };
return {};
});
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<ColumnHeadersComponent {...defaultProps} /> <ColumnHeadersComponent {...defaultProps} />

View file

@ -167,6 +167,7 @@ describe('EventColumnView', () => {
const wrapper = mount( const wrapper = mount(
<EventColumnView <EventColumnView
{...props} {...props}
timelineId={TimelineId.test}
leadingControlColumns={[testLeadingControlColumn, ...leadingControlColumns]} leadingControlColumns={[testLeadingControlColumn, ...leadingControlColumns]}
/>, />,
{ {

View file

@ -120,9 +120,9 @@ export const isEvenEqlSequence = (event: Ecs): boolean => {
}; };
/** Return eventType raw or signal or eql */ /** Return eventType raw or signal or eql */
export const getEventType = (event: Ecs): Omit<TimelineEventsType, 'all'> => { export const getEventType = (event: Ecs): Omit<TimelineEventsType, 'all'> => {
if (!isEmpty(event.kibana?.alert?.rule?.uuid)) { if (!isEmpty(event?.kibana?.alert?.rule?.uuid)) {
return 'signal'; return 'signal';
} else if (!isEmpty(event.eql?.parentId)) { } else if (!isEmpty(event?.eql?.parentId)) {
return 'eql'; return 'eql';
} }
return 'raw'; return 'raw';

View file

@ -126,7 +126,7 @@ const FormattedFieldValueComponent: React.FC<{
} else if (fieldType === GEO_FIELD_TYPE) { } else if (fieldType === GEO_FIELD_TYPE) {
return <>{value}</>; return <>{value}</>;
} else if (fieldType === DATE_FIELD_TYPE) { } else if (fieldType === DATE_FIELD_TYPE) {
const classNames = truncate ? 'eui-textTruncate eui-alignMiddle' : undefined; const classNames = truncate ? 'eui-textTruncate' : undefined;
const date = ( const date = (
<FormattedDate <FormattedDate
className={classNames} className={classNames}

View file

@ -40,6 +40,10 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => {
onChangePage, onChangePage,
activeTab, activeTab,
updatedAt, updatedAt,
trailingControlColumns,
leadingControlColumns,
pinnedEventIds,
eventIdToNoteIds,
} = props; } = props;
const [pageRows, setPageRows] = useState<TimelineItem[][]>([]); const [pageRows, setPageRows] = useState<TimelineItem[][]>([]);
@ -85,6 +89,10 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => {
activeTab={activeTab} activeTab={activeTab}
updatedAt={updatedAt} updatedAt={updatedAt}
isTextBasedQuery={false} isTextBasedQuery={false}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
pinnedEventIds={pinnedEventIds}
eventIdToNoteIds={eventIdToNoteIds}
/> />
</RootDragDropProvider> </RootDragDropProvider>
</StyledTableFlexItem> </StyledTableFlexItem>

View file

@ -6,7 +6,7 @@
*/ */
import { EuiBadge, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { EuiBadge, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React from 'react'; import React, { useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import type { TimelineTypeLiteral } from '../../../../../common/api/timeline'; import type { TimelineTypeLiteral } from '../../../../../common/api/timeline';
@ -24,23 +24,32 @@ interface NotesButtonProps {
ariaLabel?: string; ariaLabel?: string;
isDisabled?: boolean; isDisabled?: boolean;
showNotes: boolean; showNotes: boolean;
toggleShowNotes: () => void; toggleShowNotes: () => void | ((eventId: string) => void);
toolTip?: string; toolTip?: string;
timelineType: TimelineTypeLiteral; timelineType: TimelineTypeLiteral;
eventId?: string;
} }
interface SmallNotesButtonProps { interface SmallNotesButtonProps {
ariaLabel?: string; ariaLabel?: string;
isDisabled?: boolean; isDisabled?: boolean;
toggleShowNotes: () => void; toggleShowNotes: (eventId?: string) => void;
timelineType: TimelineTypeLiteral; timelineType: TimelineTypeLiteral;
eventId?: string;
} }
export const NOTES_BUTTON_CLASS_NAME = 'notes-button'; export const NOTES_BUTTON_CLASS_NAME = 'notes-button';
const SmallNotesButton = React.memo<SmallNotesButtonProps>( const SmallNotesButton = React.memo<SmallNotesButtonProps>(
({ ariaLabel = i18n.NOTES, isDisabled, toggleShowNotes, timelineType }) => { ({ ariaLabel = i18n.NOTES, isDisabled, toggleShowNotes, timelineType, eventId }) => {
const isTemplate = timelineType === TimelineType.template; const isTemplate = timelineType === TimelineType.template;
const onClick = useCallback(() => {
if (eventId != null) {
toggleShowNotes(eventId);
} else {
toggleShowNotes();
}
}, [toggleShowNotes, eventId]);
return ( return (
<EuiButtonIcon <EuiButtonIcon
@ -49,7 +58,7 @@ const SmallNotesButton = React.memo<SmallNotesButtonProps>(
data-test-subj="timeline-notes-button-small" data-test-subj="timeline-notes-button-small"
disabled={isDisabled} disabled={isDisabled}
iconType="editorComment" iconType="editorComment"
onClick={toggleShowNotes} onClick={onClick}
size="s" size="s"
isDisabled={isTemplate} isDisabled={isTemplate}
/> />
@ -59,13 +68,14 @@ const SmallNotesButton = React.memo<SmallNotesButtonProps>(
SmallNotesButton.displayName = 'SmallNotesButton'; SmallNotesButton.displayName = 'SmallNotesButton';
export const NotesButton = React.memo<NotesButtonProps>( export const NotesButton = React.memo<NotesButtonProps>(
({ ariaLabel, isDisabled, showNotes, timelineType, toggleShowNotes, toolTip }) => ({ ariaLabel, isDisabled, showNotes, timelineType, toggleShowNotes, toolTip, eventId }) =>
showNotes ? ( showNotes ? (
<SmallNotesButton <SmallNotesButton
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
isDisabled={isDisabled} isDisabled={isDisabled}
toggleShowNotes={toggleShowNotes} toggleShowNotes={toggleShowNotes}
timelineType={timelineType} timelineType={timelineType}
eventId={eventId}
/> />
) : ( ) : (
<EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip"> <EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip">
@ -74,6 +84,7 @@ export const NotesButton = React.memo<NotesButtonProps>(
isDisabled={isDisabled} isDisabled={isDisabled}
toggleShowNotes={toggleShowNotes} toggleShowNotes={toggleShowNotes}
timelineType={timelineType} timelineType={timelineType}
eventId={eventId}
/> />
</EuiToolTip> </EuiToolTip>
) )

View file

@ -159,6 +159,7 @@ In other use cases the message field can be used to concatenate different values
} }
end="2018-03-24T03:33:52.253Z" end="2018-03-24T03:33:52.253Z"
eqlOptions={Object {}} eqlOptions={Object {}}
eventIdToNoteIds={Object {}}
expandedDetail={Object {}} expandedDetail={Object {}}
isLive={false} isLive={false}
itemsPerPage={5} itemsPerPage={5}
@ -170,6 +171,7 @@ In other use cases the message field can be used to concatenate different values
] ]
} }
onEventClosed={[MockFunction]} onEventClosed={[MockFunction]}
pinnedEventIds={Object {}}
renderCellValue={[Function]} renderCellValue={[Function]}
rowRenderers={ rowRenderers={
Array [ Array [

View file

@ -85,6 +85,8 @@ describe('Timeline', () => {
start: startDate, start: startDate,
timelineId: TimelineId.test, timelineId: TimelineId.test,
timerangeKind: 'absolute', timerangeKind: 'absolute',
pinnedEventIds: {},
eventIdToNoteIds: {},
}; };
}); });

View file

@ -13,9 +13,11 @@ import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch } from 'react-redux'; import { connect, useDispatch } from 'react-redux';
import deepEqual from 'fast-deep-equal'; import deepEqual from 'fast-deep-equal';
import { InPortal } from 'react-reverse-portal'; import { InPortal } from 'react-reverse-portal';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import { DataLoadingState } from '@kbn/unified-data-table'; import { DataLoadingState } from '@kbn/unified-data-table';
import { InputsModelId } from '../../../../../common/store/inputs/constants'; import { InputsModelId } from '../../../../../common/store/inputs/constants';
import type { ControlColumnProps } from '../../../../../../common/types';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { timelineActions, timelineSelectors } from '../../../../store'; import { timelineActions, timelineSelectors } from '../../../../store';
@ -54,6 +56,7 @@ import type { TimelineTabCommonProps } from '../shared/types';
import { UnifiedTimelineBody } from '../../body/unified_timeline_body'; import { UnifiedTimelineBody } from '../../body/unified_timeline_body';
import { EqlTabHeader } from './header'; import { EqlTabHeader } from './header';
import { useTimelineColumns } from '../shared/use_timeline_columns'; import { useTimelineColumns } from '../shared/use_timeline_columns';
import { useTimelineControlColumn } from '../shared/use_timeline_control_columns';
export type Props = TimelineTabCommonProps & PropsFromRedux; export type Props = TimelineTabCommonProps & PropsFromRedux;
@ -73,6 +76,8 @@ export const EqlTabContentComponent: React.FC<Props> = ({
showExpandedDetails, showExpandedDetails,
start, start,
timerangeKind, timerangeKind,
pinnedEventIds,
eventIdToNoteIds,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { query: eqlQuery = '', ...restEqlOption } = eqlOptions; const { query: eqlQuery = '', ...restEqlOption } = eqlOptions;
@ -85,8 +90,9 @@ export const EqlTabContentComponent: React.FC<Props> = ({
runtimeMappings, runtimeMappings,
selectedPatterns, selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline); } = useSourcererDataView(SourcererScopeName.timeline);
const { augmentedColumnHeaders, getTimelineQueryFieldsFromColumns, leadingControlColumns } = const { augmentedColumnHeaders, timelineQueryFieldsFromColumns } = useTimelineColumns(columns);
useTimelineColumns(columns);
const leadingControlColumns = useTimelineControlColumn(columns, TIMELINE_NO_SORTING);
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled( const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled' 'unifiedComponentsInTimelineEnabled'
@ -119,7 +125,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({
dataViewId, dataViewId,
endDate: end, endDate: end,
eqlOptions: restEqlOption, eqlOptions: restEqlOption,
fields: getTimelineQueryFieldsFromColumns(), fields: timelineQueryFieldsFromColumns,
filterQuery: eqlQuery ?? '', filterQuery: eqlQuery ?? '',
id: timelineId, id: timelineId,
indexNames: selectedPatterns, indexNames: selectedPatterns,
@ -195,6 +201,9 @@ export const EqlTabContentComponent: React.FC<Props> = ({
updatedAt={refreshedAt} updatedAt={refreshedAt}
isTextBasedQuery={false} isTextBasedQuery={false}
pageInfo={pageInfo} pageInfo={pageInfo}
leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]}
pinnedEventIds={pinnedEventIds}
eventIdToNoteIds={eventIdToNoteIds}
/> />
</ScrollableFlexItem> </ScrollableFlexItem>
</FullWidthFlexGroup> </FullWidthFlexGroup>
@ -238,7 +247,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({
itemsCount: totalCount, itemsCount: totalCount,
itemsPerPage, itemsPerPage,
})} })}
leadingControlColumns={leadingControlColumns} leadingControlColumns={leadingControlColumns as ControlColumnProps[]}
trailingControlColumns={timelineEmptyTrailingControlColumns} trailingControlColumns={timelineEmptyTrailingControlColumns}
/> />
</StyledEuiFlyoutBody> </StyledEuiFlyoutBody>
@ -293,8 +302,16 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state: State, { timelineId }: TimelineTabCommonProps) => { const mapStateToProps = (state: State, { timelineId }: TimelineTabCommonProps) => {
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const input: inputsModel.InputsRange = getInputsTimeline(state); const input: inputsModel.InputsRange = getInputsTimeline(state);
const { activeTab, columns, eqlOptions, expandedDetail, itemsPerPage, itemsPerPageOptions } = const {
timeline; activeTab,
columns,
eqlOptions,
expandedDetail,
itemsPerPage,
itemsPerPageOptions,
pinnedEventIds,
eventIdToNoteIds,
} = timeline;
return { return {
activeTab, activeTab,
@ -306,6 +323,8 @@ const makeMapStateToProps = () => {
isLive: input.policy.kind === 'interval', isLive: input.policy.kind === 'interval',
itemsPerPage, itemsPerPage,
itemsPerPageOptions, itemsPerPageOptions,
pinnedEventIds,
eventIdToNoteIds,
showExpandedDetails: showExpandedDetails:
!!expandedDetail[TimelineTabs.eql] && !!expandedDetail[TimelineTabs.eql]?.panelView, !!expandedDetail[TimelineTabs.eql] && !!expandedDetail[TimelineTabs.eql]?.panelView,
@ -338,6 +357,8 @@ const EqlTabContent = connector(
prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.showExpandedDetails === nextProps.showExpandedDetails &&
prevProps.timelineId === nextProps.timelineId && prevProps.timelineId === nextProps.timelineId &&
deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) &&
deepEqual(prevProps.eventIdToNoteIds, nextProps.eventIdToNoteIds) &&
deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions)
) )
); );

View file

@ -156,6 +156,8 @@ In other use cases the message field can be used to concatenate different values
}, },
] ]
} }
eventIdToNoteIds={Object {}}
expandedDetail={Object {}}
itemsPerPage={5} itemsPerPage={5}
itemsPerPageOptions={ itemsPerPageOptions={
Array [ Array [

View file

@ -125,6 +125,8 @@ describe('PinnedTabContent', () => {
pinnedEventIds: {}, pinnedEventIds: {},
showExpandedDetails: false, showExpandedDetails: false,
onEventClosed: jest.fn(), onEventClosed: jest.fn(),
eventIdToNoteIds: {},
expandedDetail: {},
}; };
}); });

View file

@ -6,13 +6,13 @@
*/ */
import { isEmpty } from 'lodash/fp'; import { isEmpty } from 'lodash/fp';
import React, { useMemo, useCallback } from 'react'; import React, { useMemo, useCallback, memo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import deepEqual from 'fast-deep-equal'; import deepEqual from 'fast-deep-equal';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import { DataLoadingState } from '@kbn/unified-data-table'; import { DataLoadingState } from '@kbn/unified-data-table';
import type { ControlColumnProps } from '../../../../../../common/types'; import type { ControlColumnProps } from '../../../../../../common/types';
import { timelineActions, timelineSelectors } from '../../../../store'; import { timelineActions, timelineSelectors } from '../../../../store';
@ -25,6 +25,7 @@ import { requiredFieldsForActions } from '../../../../../detections/components/a
import { EventDetailsWidthProvider } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventDetailsWidthProvider } from '../../../../../common/components/events_viewer/event_details_width_context';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { timelineDefaults } from '../../../../store/defaults'; import { timelineDefaults } from '../../../../store/defaults';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer'; import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useTimelineFullScreen } from '../../../../../common/containers/use_full_screen'; import { useTimelineFullScreen } from '../../../../../common/containers/use_full_screen';
import type { TimelineModel } from '../../../../store/model'; import type { TimelineModel } from '../../../../store/model';
@ -34,9 +35,7 @@ import type { ToggleDetailPanel } from '../../../../../../common/types/timeline'
import { TimelineTabs } from '../../../../../../common/types/timeline'; import { TimelineTabs } from '../../../../../../common/types/timeline';
import { DetailsPanel } from '../../../side_panel'; import { DetailsPanel } from '../../../side_panel';
import { ExitFullScreen } from '../../../../../common/components/exit_full_screen'; import { ExitFullScreen } from '../../../../../common/components/exit_full_screen';
import { getDefaultControlColumn } from '../../body/control_columns'; import { UnifiedTimelineBody } from '../../body/unified_timeline_body';
import { useLicense } from '../../../../../common/hooks/use_license';
import { HeaderActions } from '../../../../../common/components/header_actions/header_actions';
import { import {
FullWidthFlexGroup, FullWidthFlexGroup,
ScrollableFlexItem, ScrollableFlexItem,
@ -45,10 +44,13 @@ import {
VerticalRule, VerticalRule,
} from '../shared/layout'; } from '../shared/layout';
import type { TimelineTabCommonProps } from '../shared/types'; import type { TimelineTabCommonProps } from '../shared/types';
import { useTimelineColumns } from '../shared/use_timeline_columns';
import { useTimelineControlColumn } from '../shared/use_timeline_control_columns';
const ExitFullScreenContainer = styled.div` const ExitFullScreenContainer = styled.div`
width: 180px; width: 180px;
`; `;
interface PinnedFilter { interface PinnedFilter {
bool: { bool: {
should: Array<{ match_phrase: { _id: string } }>; should: Array<{ match_phrase: { _id: string } }>;
@ -60,6 +62,16 @@ export type Props = TimelineTabCommonProps & PropsFromRedux;
const trailingControlColumns: ControlColumnProps[] = []; // stable reference const trailingControlColumns: ControlColumnProps[] = []; // stable reference
const rowDetailColumn = [
{
id: 'row-details',
columnHeaderType: 'not-filtered',
width: 0,
headerCellRender: () => null,
rowCellRender: () => null,
},
];
export const PinnedTabContentComponent: React.FC<Props> = ({ export const PinnedTabContentComponent: React.FC<Props> = ({
columns, columns,
timelineId, timelineId,
@ -71,6 +83,8 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
rowRenderers, rowRenderers,
showExpandedDetails, showExpandedDetails,
sort, sort,
expandedDetail,
eventIdToNoteIds,
}) => { }) => {
const { const {
browserFields, browserFields,
@ -80,8 +94,9 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
selectedPatterns, selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline); } = useSourcererDataView(SourcererScopeName.timeline);
const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen(); const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen();
const isEnterprisePlus = useLicense().isEnterprise(); const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; 'unifiedComponentsInTimelineEnabled'
);
const filterQuery = useMemo(() => { const filterQuery = useMemo(() => {
if (isEmpty(pinnedEventIds)) { if (isEmpty(pinnedEventIds)) {
@ -138,6 +153,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
})), })),
[sort] [sort]
); );
const { augmentedColumnHeaders } = useTimelineColumns(columns);
const [queryLoadingState, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] = const [queryLoadingState, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] =
useTimelineEvents({ useTimelineEvents({
@ -155,6 +171,8 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
timerangeKind: undefined, timerangeKind: undefined,
}); });
const leadingControlColumns = useTimelineControlColumn(columns, sort);
const isQueryLoading = useMemo( const isQueryLoading = useMemo(
() => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(queryLoadingState), () => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(queryLoadingState),
[queryLoadingState] [queryLoadingState]
@ -164,14 +182,35 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
onEventClosed({ tabType: TimelineTabs.pinned, id: timelineId }); onEventClosed({ tabType: TimelineTabs.pinned, id: timelineId });
}, [timelineId, onEventClosed]); }, [timelineId, onEventClosed]);
const leadingControlColumns = useMemo( if (unifiedComponentsInTimelineEnabled) {
() => return (
getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ <UnifiedTimelineBody
...x, header={<></>}
headerCellRender: HeaderActions, columns={augmentedColumnHeaders}
})), rowRenderers={rowRenderers}
[ACTION_BUTTON_COUNT] timelineId={timelineId}
); itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
sort={sort}
events={events}
refetch={refetch}
dataLoadingState={queryLoadingState}
pinnedEventIds={pinnedEventIds}
totalCount={events.length}
onEventClosed={onEventClosed}
expandedDetail={expandedDetail}
eventIdToNoteIds={eventIdToNoteIds}
showExpandedDetails={showExpandedDetails}
onChangePage={loadPage}
activeTab={TimelineTabs.pinned}
updatedAt={refreshedAt}
isTextBasedQuery={false}
pageInfo={pageInfo}
leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]}
trailingControlColumns={rowDetailColumn}
/>
);
}
return ( return (
<> <>
@ -204,7 +243,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
itemsCount: totalCount, itemsCount: totalCount,
itemsPerPage, itemsPerPage,
})} })}
leadingControlColumns={leadingControlColumns} leadingControlColumns={leadingControlColumns as ControlColumnProps[]}
trailingControlColumns={trailingControlColumns} trailingControlColumns={trailingControlColumns}
/> />
</StyledEuiFlyoutBody> </StyledEuiFlyoutBody>
@ -252,8 +291,15 @@ const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector(); const getTimeline = timelineSelectors.getTimelineByIdSelector();
const mapStateToProps = (state: State, { timelineId }: TimelineTabCommonProps) => { const mapStateToProps = (state: State, { timelineId }: TimelineTabCommonProps) => {
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const { columns, expandedDetail, itemsPerPage, itemsPerPageOptions, pinnedEventIds, sort } = const {
timeline; columns,
expandedDetail,
itemsPerPage,
itemsPerPageOptions,
pinnedEventIds,
sort,
eventIdToNoteIds,
} = timeline;
return { return {
columns, columns,
@ -264,6 +310,8 @@ const makeMapStateToProps = () => {
showExpandedDetails: showExpandedDetails:
!!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView, !!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView,
sort, sort,
expandedDetail,
eventIdToNoteIds,
}; };
}; };
return mapStateToProps; return mapStateToProps;
@ -280,7 +328,7 @@ const connector = connect(makeMapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>; type PropsFromRedux = ConnectedProps<typeof connector>;
const PinnedTabContent = connector( const PinnedTabContent = connector(
React.memo( memo(
PinnedTabContentComponent, PinnedTabContentComponent,
(prevProps, nextProps) => (prevProps, nextProps) =>
prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.itemsPerPage === nextProps.itemsPerPage &&
@ -288,6 +336,7 @@ const PinnedTabContent = connector(
prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.showExpandedDetails === nextProps.showExpandedDetails &&
prevProps.timelineId === nextProps.timelineId && prevProps.timelineId === nextProps.timelineId &&
deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.eventIdToNoteIds, nextProps.eventIdToNoteIds) &&
deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) &&
deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) && deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) &&
deepEqual(prevProps.sort, nextProps.sort) deepEqual(prevProps.sort, nextProps.sort)

View file

@ -292,6 +292,7 @@ In other use cases the message field can be used to concatenate different values
] ]
} }
end="2018-03-24T03:33:52.253Z" end="2018-03-24T03:33:52.253Z"
eventIdToNoteIds={Object {}}
expandedDetail={Object {}} expandedDetail={Object {}}
filters={Array []} filters={Array []}
isLive={false} isLive={false}
@ -307,6 +308,7 @@ In other use cases the message field can be used to concatenate different values
kqlQueryExpression=" " kqlQueryExpression=" "
kqlQueryLanguage="kuery" kqlQueryLanguage="kuery"
onEventClosed={[MockFunction]} onEventClosed={[MockFunction]}
pinnedEventIds={Object {}}
renderCellValue={[Function]} renderCellValue={[Function]}
rowRenderers={ rowRenderers={
Array [ Array [

View file

@ -113,6 +113,8 @@ describe('Timeline', () => {
timerangeKind: 'absolute', timerangeKind: 'absolute',
activeTab: TimelineTabs.query, activeTab: TimelineTabs.query,
show: true, show: true,
pinnedEventIds: {},
eventIdToNoteIds: {},
}; };
}); });

View file

@ -11,6 +11,7 @@ import type { Dispatch } from 'redux';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch } from 'react-redux'; import { connect, useDispatch } from 'react-redux';
import deepEqual from 'fast-deep-equal'; import deepEqual from 'fast-deep-equal';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { DataLoadingState } from '@kbn/unified-data-table'; import { DataLoadingState } from '@kbn/unified-data-table';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
@ -19,6 +20,7 @@ import { InputsModelId } from '../../../../../common/store/inputs/constants';
import { useInvalidFilterQuery } from '../../../../../common/hooks/use_invalid_filter_query'; import { useInvalidFilterQuery } from '../../../../../common/hooks/use_invalid_filter_query';
import { timelineActions, timelineSelectors } from '../../../../store'; import { timelineActions, timelineSelectors } from '../../../../store';
import type { Direction } from '../../../../../../common/search_strategy'; import type { Direction } from '../../../../../../common/search_strategy';
import type { ControlColumnProps } from '../../../../../../common/types';
import { useTimelineEvents } from '../../../../containers'; import { useTimelineEvents } from '../../../../containers';
import { useKibana } from '../../../../../common/lib/kibana'; import { useKibana } from '../../../../../common/lib/kibana';
import { StatefulBody } from '../../body'; import { StatefulBody } from '../../body';
@ -55,6 +57,7 @@ import {
} from '../shared/utils'; } from '../shared/utils';
import type { TimelineTabCommonProps } from '../shared/types'; import type { TimelineTabCommonProps } from '../shared/types';
import { useTimelineColumns } from '../shared/use_timeline_columns'; import { useTimelineColumns } from '../shared/use_timeline_columns';
import { useTimelineControlColumn } from '../shared/use_timeline_control_columns';
const compareQueryProps = (prevProps: Props, nextProps: Props) => const compareQueryProps = (prevProps: Props, nextProps: Props) =>
prevProps.kqlMode === nextProps.kqlMode && prevProps.kqlMode === nextProps.kqlMode &&
@ -87,6 +90,8 @@ export const QueryTabContentComponent: React.FC<Props> = ({
sort, sort,
timerangeKind, timerangeKind,
expandedDetail, expandedDetail,
pinnedEventIds,
eventIdToNoteIds,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { const {
@ -99,12 +104,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({
// in order to include the exclude filters in the search that are not stored in the timeline // in order to include the exclude filters in the search that are not stored in the timeline
selectedPatterns, selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline); } = useSourcererDataView(SourcererScopeName.timeline);
const {
augmentedColumnHeaders,
defaultColumns,
getTimelineQueryFieldsFromColumns,
leadingControlColumns,
} = useTimelineColumns(columns);
const { uiSettings, timelineFilterManager } = useKibana().services; const { uiSettings, timelineFilterManager } = useKibana().services;
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled( const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
@ -171,14 +170,8 @@ export const QueryTabContentComponent: React.FC<Props> = ({
type: columnType, type: columnType,
})); }));
useEffect(() => { const { augmentedColumnHeaders, defaultColumns, timelineQueryFieldsFromColumns } =
dispatch( useTimelineColumns(columns);
timelineActions.initializeTimelineSettings({
id: timelineId,
defaultColumns,
})
);
}, [dispatch, timelineId, defaultColumns]);
const [ const [
dataLoadingState, dataLoadingState,
@ -186,7 +179,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
] = useTimelineEvents({ ] = useTimelineEvents({
dataViewId, dataViewId,
endDate: end, endDate: end,
fields: getTimelineQueryFieldsFromColumns(), fields: timelineQueryFieldsFromColumns,
filterQuery: combinedQueries?.filterQuery, filterQuery: combinedQueries?.filterQuery,
id: timelineId, id: timelineId,
indexNames: selectedPatterns, indexNames: selectedPatterns,
@ -199,6 +192,17 @@ export const QueryTabContentComponent: React.FC<Props> = ({
timerangeKind, timerangeKind,
}); });
const leadingControlColumns = useTimelineControlColumn(columns, sort);
useEffect(() => {
dispatch(
timelineActions.initializeTimelineSettings({
id: timelineId,
defaultColumns,
})
);
}, [dispatch, timelineId, defaultColumns]);
const isQueryLoading = useMemo( const isQueryLoading = useMemo(
() => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(dataLoadingState), () => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(dataLoadingState),
[dataLoadingState] [dataLoadingState]
@ -250,6 +254,9 @@ export const QueryTabContentComponent: React.FC<Props> = ({
onEventClosed={onEventClosed} onEventClosed={onEventClosed}
expandedDetail={expandedDetail} expandedDetail={expandedDetail}
showExpandedDetails={showExpandedDetails} showExpandedDetails={showExpandedDetails}
leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]}
eventIdToNoteIds={eventIdToNoteIds}
pinnedEventIds={pinnedEventIds}
onChangePage={loadPage} onChangePage={loadPage}
activeTab={activeTab} activeTab={activeTab}
updatedAt={refreshedAt} updatedAt={refreshedAt}
@ -300,7 +307,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
itemsCount: totalCount, itemsCount: totalCount,
itemsPerPage, itemsPerPage,
})} })}
leadingControlColumns={leadingControlColumns} leadingControlColumns={leadingControlColumns as ControlColumnProps[]}
trailingControlColumns={timelineEmptyTrailingControlColumns} trailingControlColumns={timelineEmptyTrailingControlColumns}
/> />
</StyledEuiFlyoutBody> </StyledEuiFlyoutBody>
@ -359,6 +366,8 @@ const makeMapStateToProps = () => {
activeTab, activeTab,
columns, columns,
dataProviders, dataProviders,
pinnedEventIds,
eventIdToNoteIds,
expandedDetail, expandedDetail,
filters, filters,
itemsPerPage, itemsPerPage,
@ -394,6 +403,8 @@ const makeMapStateToProps = () => {
expandedDetail, expandedDetail,
filters: timelineFilter, filters: timelineFilter,
timelineId, timelineId,
pinnedEventIds,
eventIdToNoteIds,
isLive: input.policy.kind === 'interval', isLive: input.policy.kind === 'interval',
itemsPerPage, itemsPerPage,
itemsPerPageOptions, itemsPerPageOptions,
@ -437,8 +448,11 @@ const QueryTabContent = connector(
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.showExpandedDetails === nextProps.showExpandedDetails &&
prevProps.status === nextProps.status && prevProps.status === nextProps.status &&
prevProps.status === nextProps.status &&
prevProps.timelineId === nextProps.timelineId && prevProps.timelineId === nextProps.timelineId &&
deepEqual(prevProps.eventIdToNoteIds, nextProps.eventIdToNoteIds) &&
deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) &&
deepEqual(prevProps.dataProviders, nextProps.dataProviders) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) &&
deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) &&
deepEqual(prevProps.sort, nextProps.sort) deepEqual(prevProps.sort, nextProps.sort)

View file

@ -472,26 +472,3 @@ Array [
"process.entry_leader.entity_id", "process.entry_leader.entity_id",
] ]
`; `;
exports[`useTimelineColumns leadingControlColumns should return the leading control columns 1`] = `
Array [
Object {
"headerCellRender": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
},
"id": "default-timeline-control-column",
"rowCellRender": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"width": 180,
},
]
`;

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`useTimelineColumns leadingControlColumns should return the leading control columns 1`] = `
Array [
Object {
"headerCellRender": [Function],
"id": "default-timeline-control-column",
"rowCellRender": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"width": 152,
},
]
`;

View file

@ -8,7 +8,6 @@
import { TestProviders } from '../../../../../common/mock'; import { TestProviders } from '../../../../../common/mock';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useTimelineColumns } from './use_timeline_columns'; import { useTimelineColumns } from './use_timeline_columns';
import { defaultUdtHeaders } from '../../unified_components/default_headers'; import { defaultUdtHeaders } from '../../unified_components/default_headers';
import { defaultHeaders } from '../../body/column_headers/default_headers'; import { defaultHeaders } from '../../body/column_headers/default_headers';
@ -20,14 +19,6 @@ jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
jest.mock('../../../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({
isEnterprise: () => true,
}),
}));
const useLicenseMock = useLicense as jest.Mock;
describe('useTimelineColumns', () => { describe('useTimelineColumns', () => {
const mockColumns: ColumnHeaderOptions[] = [ const mockColumns: ColumnHeaderOptions[] = [
{ {
@ -108,45 +99,18 @@ describe('useTimelineColumns', () => {
}); });
}); });
describe('leadingControlColumns', () => {
it('should return the leading control columns', () => {
const { result } = renderHook(() => useTimelineColumns([]), {
wrapper: TestProviders,
});
expect(result.current.leadingControlColumns).toMatchSnapshot();
});
it('should have a width of 152 for 5 actions', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => false,
});
const { result } = renderHook(() => useTimelineColumns([]), {
wrapper: TestProviders,
});
expect(result.current.leadingControlColumns[0].width).toBe(152);
});
it('should have a width of 180 for 6 actions', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => true,
});
const { result } = renderHook(() => useTimelineColumns([]), {
wrapper: TestProviders,
});
expect(result.current.leadingControlColumns[0].width).toBe(180);
});
});
describe('getTimelineQueryFieldsFromColumns', () => { describe('getTimelineQueryFieldsFromColumns', () => {
it('should return the list of all the fields', () => { it('should return the list of all the fields', () => {
const { result } = renderHook(() => useTimelineColumns([]), { const { result } = renderHook(() => useTimelineColumns([]), {
wrapper: TestProviders, wrapper: TestProviders,
}); });
expect(result.current.getTimelineQueryFieldsFromColumns()).toMatchSnapshot(); expect(result.current.timelineQueryFieldsFromColumns).toMatchSnapshot();
}); });
it('should have a width of 152 for 5 actions', () => { it('should have a width of 152 for 5 actions', () => {
const { result } = renderHook(() => useTimelineColumns(mockColumns), { const { result } = renderHook(() => useTimelineColumns(mockColumns), {
wrapper: TestProviders, wrapper: TestProviders,
}); });
expect(result.current.getTimelineQueryFieldsFromColumns()).toMatchSnapshot(); expect(result.current.timelineQueryFieldsFromColumns).toMatchSnapshot();
}); });
}); });
}); });

View file

@ -6,15 +6,12 @@
*/ */
import { isEmpty } from 'lodash/fp'; import { isEmpty } from 'lodash/fp';
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { useLicense } from '../../../../../common/hooks/use_license';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer'; import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { defaultHeaders } from '../../body/column_headers/default_headers'; import { defaultHeaders } from '../../body/column_headers/default_headers';
import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config'; import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config';
import { getDefaultControlColumn } from '../../body/control_columns';
import { HeaderActions } from '../../../../../common/components/header_actions/header_actions';
import { defaultUdtHeaders } from '../../unified_components/default_headers'; import { defaultUdtHeaders } from '../../unified_components/default_headers';
import type { ColumnHeaderOptions } from '../../../../../../common/types'; import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { memoizedGetTimelineColumnHeaders } from './utils'; import { memoizedGetTimelineColumnHeaders } from './utils';
@ -26,9 +23,6 @@ export const useTimelineColumns = (columns: ColumnHeaderOptions[]) => {
'unifiedComponentsInTimelineEnabled' 'unifiedComponentsInTimelineEnabled'
); );
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
const defaultColumns = useMemo( const defaultColumns = useMemo(
() => (unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders), () => (unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders),
[unifiedComponentsInTimelineEnabled] [unifiedComponentsInTimelineEnabled]
@ -45,35 +39,19 @@ export const useTimelineColumns = (columns: ColumnHeaderOptions[]) => {
false false
); );
const getTimelineQueryFieldsFromColumns = useCallback(() => { const timelineQueryFieldsFromColumns = useMemo(() => {
const columnFields = augmentedColumnHeaders.map((c) => c.id); const columnFields = augmentedColumnHeaders.map((c) => c.id);
return [...columnFields, ...requiredFieldsForActions]; return [...columnFields, ...requiredFieldsForActions];
}, [augmentedColumnHeaders]); }, [augmentedColumnHeaders]);
const leadingControlColumns = useMemo(
() =>
getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
...x,
headerCellRender: HeaderActions,
})),
[ACTION_BUTTON_COUNT]
);
return useMemo( return useMemo(
() => ({ () => ({
defaultColumns, defaultColumns,
localColumns, localColumns,
augmentedColumnHeaders, augmentedColumnHeaders,
getTimelineQueryFieldsFromColumns, timelineQueryFieldsFromColumns,
leadingControlColumns,
}), }),
[ [augmentedColumnHeaders, defaultColumns, timelineQueryFieldsFromColumns, localColumns]
augmentedColumnHeaders,
defaultColumns,
getTimelineQueryFieldsFromColumns,
leadingControlColumns,
localColumns,
]
); );
}; };

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiDataGridControlColumn } from '@elastic/eui';
import { TestProviders } from '../../../../../common/mock';
import { renderHook } from '@testing-library/react-hooks';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useTimelineControlColumn } from './use_timeline_control_columns';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline/columns';
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
jest.mock('../../../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({
isEnterprise: () => true,
}),
}));
const useLicenseMock = useLicense as jest.Mock;
describe('useTimelineColumns', () => {
const mockColumns: ColumnHeaderOptions[] = [
{
columnHeaderType: 'not-filtered',
id: 'source.ip',
initialWidth: 150,
},
{
columnHeaderType: 'not-filtered',
id: 'agent.type',
initialWidth: 150,
},
];
describe('leadingControlColumns', () => {
it('should return the leading control columns', () => {
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
expect(result.current).toMatchSnapshot();
});
it('should have a width of 124 for 5 actions', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => false,
});
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
const controlColumn = result.current[0] as EuiDataGridControlColumn;
expect(controlColumn.width).toBe(124);
});
it('should have a width of 152 for 6 actions', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => true,
});
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
const controlColumn = result.current[0] as EuiDataGridControlColumn;
expect(controlColumn.width).toBe(152);
});
});
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import type { SortColumnTable } from '@kbn/securitysolution-data-table';
import { useLicense } from '../../../../../common/hooks/use_license';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { getDefaultControlColumn } from '../../body/control_columns';
import type { UnifiedActionProps } from '../../unified_components/data_table/control_column_cell_render';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { HeaderActions } from '../../../../../common/components/header_actions/header_actions';
import { ControlColumnCellRender } from '../../unified_components/data_table/control_column_cell_render';
import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { useTimelineColumns } from './use_timeline_columns';
const noSelectAll = ({ isSelected }: { isSelected: boolean }) => {};
export const useTimelineControlColumn = (
columns: ColumnHeaderOptions[],
sort: SortColumnTable[]
) => {
const { browserFields } = useSourcererDataView(SourcererScopeName.timeline);
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
const { localColumns } = useTimelineColumns(columns);
// We need one less when the unified components are enabled because the document expand is provided by the unified data table
const UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT = ACTION_BUTTON_COUNT - 1;
return useMemo(() => {
if (unifiedComponentsInTimelineEnabled) {
return getDefaultControlColumn(UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT).map((x) => ({
...x,
headerCellRender: function HeaderCellRender(props: UnifiedActionProps) {
return (
<HeaderActions
width={x.width}
browserFields={browserFields}
columnHeaders={localColumns}
isEventViewer={false}
isSelectAllChecked={false}
onSelectAll={noSelectAll}
showEventsSelect={false}
showSelectAllCheckbox={false}
showFullScreenToggle={false}
sort={sort}
tabType={TimelineTabs.pinned}
{...props}
timelineId={TimelineId.active}
/>
);
},
rowCellRender: ControlColumnCellRender,
})) as unknown as EuiDataGridControlColumn[];
} else {
return getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
...x,
headerCellRender: HeaderActions,
})) as unknown as ColumnHeaderOptions[];
}
}, [
ACTION_BUTTON_COUNT,
UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT,
browserFields,
localColumns,
sort,
unifiedComponentsInTimelineEnabled,
]);
};

View file

@ -1,6 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = ` exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
.c3 {
padding-top: 8px;
}
.c2 {
border: none;
background-color: transparent;
box-shadow: none;
}
.c2.euiPanel--plain {
background-color: transparent;
}
.c4 {
margin-bottom: 5px;
}
.c0 { .c0 {
width: -webkit-fit-content; width: -webkit-fit-content;
width: -moz-fit-content; width: -moz-fit-content;
@ -8,11 +26,52 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
border-bottom: 1px solid 1px solid #343741; border-bottom: 1px solid 1px solid #343741;
} }
.c0 . euiDataGridRowCell--controlColumn {
height: 40px;
}
.c0 .udt--customRow {
border-radius: 0;
padding: 6px;
max-width: 1200px;
width: 85vw;
}
.c0 .euiCommentEvent__body {
background-color: #1d1e24;
}
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--firstColumn,
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--lastColumn,
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--controlColumn,
.c0:has(.unifiedDataTable__cell--expanded) .udt--customRow {
background-color: #2e2d25;
}
.c1 { .c1 {
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
display: -ms-flexbox; display: -ms-flexbox;
display: flex; display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 36px;
}
.c1 .euiDataGridRowCell,
.c1 .euiDataGridRowCell__content {
height: 100%;
}
.c1 .euiDataGridRowCell .unifiedDataTable__rowControl,
.c1 .euiDataGridRowCell__content .unifiedDataTable__rowControl {
margin-top: 0;
}
.c1 .euiDataGridRowCell--controlColumn .euiDataGridRowCell__content {
padding: 0;
} }
<div> <div>
@ -34,6 +93,140 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
Cell-0-2 Cell-0-2
</div> </div>
</div> </div>
<div
class="euiPanel euiPanel--plain c2 udt--customRow emotion-euiPanel-grow-m-plain"
data-test-subj="note-cards"
>
<section
class="c3"
data-test-subj="note-previews-container"
>
<div
class="euiFlexGroup c4 notes-container-0 emotion-euiFlexGroup-none-flexStart-stretch-column"
data-test-subj="notes"
>
<p
class="emotion-euiScreenReaderOnly"
>
You are viewing notes for the event in row 0. Press the up arrow key when finished to return to the event.
</p>
<ol
class="euiTimeline euiCommentList emotion-euiTimeline-l"
data-test-subj="note-comment-list"
role="list"
>
<li
class="euiComment emotion-euiTimelineItem-top"
data-test-subj="note-preview-id"
>
<div
class="emotion-euiTimelineItemIcon-top"
>
<div
class="emotion-euiTimelineItemIcon__content"
>
<div
aria-label="test"
class="euiAvatar euiAvatar--l euiAvatar--user emotion-euiAvatar-user-l-uppercase"
data-test-subj="avatar"
role="img"
style="background-color: rgb(228, 166, 199); color: rgb(0, 0, 0);"
title="test"
>
<span
aria-hidden="true"
>
t
</span>
</div>
</div>
</div>
<div
class="emotion-euiTimelineItemEvent-top"
>
<figure
class="euiCommentEvent emotion-euiCommentEvent-border-subdued"
data-type="regular"
>
<figcaption
class="euiCommentEvent__header emotion-euiCommentEvent__header-border-subdued"
>
<div
class="euiPanel euiPanel--subdued euiPanel--paddingSmall emotion-euiPanel-grow-none-s-subdued"
>
<div
class="euiCommentEvent__headerMain emotion-euiCommentEvent__headerMain"
>
<div
class="euiCommentEvent__headerData emotion-euiCommentEvent__headerData"
>
<div
class="euiCommentEvent__headerUsername emotion-euiCommentEvent__headerUsername"
>
test
</div>
<div
class="euiCommentEvent__headerEvent emotion-euiCommentEvent__headerEvent"
>
added a note
</div>
<div
class="euiCommentEvent__headerTimestamp"
>
<time>
now
</time>
</div>
</div>
<div
class="euiCommentEvent__headerActions emotion-euiCommentEvent__headerActions"
>
<button
aria-label="Delete Note"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-text"
data-test-subj="delete-note"
title="Delete Note"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="trash"
/>
</button>
</div>
</div>
</div>
</figcaption>
<div
class="euiCommentEvent__body emotion-euiCommentEvent__body-regular"
>
<div
class="note-content"
tabindex="0"
>
<p
class="emotion-euiScreenReaderOnly"
>
test added a note
</p>
<div
class="euiText euiMarkdownFormat emotion-euiText-m-euiMarkdownFormat-m"
>
<p>
note
</p>
</div>
</div>
</div>
</figure>
</div>
</li>
</ol>
</div>
</section>
</div>
<div> <div>
Cell-0-3 Cell-0-3
</div> </div>

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import { eventIsPinned } from '../../body/helpers';
import { Actions } from '../../../../../common/components/header_actions';
import { TimelineId } from '../../../../../../common/types';
import type { TimelineModel } from '../../../../store/model';
import type { ActionProps } from '../../../../../../common/types';
const noOp = () => {};
const emptyLoadingEventIds: string[] = [];
export interface UnifiedActionProps extends ActionProps {
onToggleShowNotes: (eventId?: string) => void;
events: TimelineItem[];
pinnedEventIds: TimelineModel['pinnedEventIds'];
}
export const ControlColumnCellRender = memo(function RowCellRender(props: UnifiedActionProps) {
const { rowIndex, events, ecsData, pinnedEventIds, onToggleShowNotes, eventIdToNoteIds } = props;
const event = useMemo(() => events && events[rowIndex], [events, rowIndex]);
const isPinned = useMemo(
() => eventIsPinned({ eventId: event?._id, pinnedEventIds }),
[event?._id, pinnedEventIds]
);
return (
<Actions
{...props}
ecsData={ecsData ?? event.ecs}
ariaRowindex={rowIndex}
rowIndex={rowIndex}
checked={false}
columnValues="columnValues"
eventId={event?._id}
eventIdToNoteIds={eventIdToNoteIds}
isEventPinned={isPinned}
isEventViewer={false}
loadingEventIds={emptyLoadingEventIds}
onEventDetailsPanelOpened={noOp}
onRowSelected={noOp}
onRuleChange={noOp}
showCheckboxes={false}
showNotes={true}
timelineId={TimelineId.active}
toggleShowNotes={onToggleShowNotes}
refetch={noOp}
/>
);
});

View file

@ -16,6 +16,9 @@ import { defaultUdtHeaders } from '../default_headers';
import type { EuiDataGridColumn } from '@elastic/eui'; import type { EuiDataGridColumn } from '@elastic/eui';
import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer';
import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
jest.mock('../../../../../common/hooks/use_selector');
const testDataRows = structuredClone(mockTimelineData); const testDataRows = structuredClone(mockTimelineData);
@ -37,6 +40,8 @@ const mockVisibleColumns = ['@timestamp', 'message', 'user.name']
.map((id) => defaultUdtHeaders.find((h) => h.id === id) as EuiDataGridColumn) .map((id) => defaultUdtHeaders.find((h) => h.id === id) as EuiDataGridColumn)
.concat(additionalTrailingColumn); .concat(additionalTrailingColumn);
const mockEventIdsAddingNotes = new Set<string>();
const defaultProps: CustomTimelineDataGridBodyProps = { const defaultProps: CustomTimelineDataGridBodyProps = {
Cell: MockCellComponent, Cell: MockCellComponent,
visibleRowData: { startRow: 0, endRow: 2, visibleRowCount: 2 }, visibleRowData: { startRow: 0, endRow: 2, visibleRowCount: 2 },
@ -44,6 +49,34 @@ const defaultProps: CustomTimelineDataGridBodyProps = {
enabledRowRenderers: [], enabledRowRenderers: [],
setCustomGridBodyProps: jest.fn(), setCustomGridBodyProps: jest.fn(),
visibleColumns: mockVisibleColumns, visibleColumns: mockVisibleColumns,
eventIdsAddingNotes: mockEventIdsAddingNotes,
eventIdToNoteIds: {
event1: ['noteId1', 'noteId2'],
event2: ['noteId3'],
},
events: [
{
_id: 'event1',
_index: 'logs-*',
data: [],
ecs: { _id: 'event1', _index: 'logs-*' },
},
{
_id: 'event2',
_index: 'logs-*',
data: [],
ecs: { _id: 'event2', _index: 'logs-*' },
},
],
onToggleShowNotes: (eventId?: string) => {
if (eventId) {
if (mockEventIdsAddingNotes.has(eventId)) {
mockEventIdsAddingNotes.delete(eventId);
} else {
mockEventIdsAddingNotes.add(eventId);
}
}
},
}; };
const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => { const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => {
@ -58,9 +91,34 @@ const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => {
describe('CustomTimelineDataGridBody', () => { describe('CustomTimelineDataGridBody', () => {
beforeEach(() => { beforeEach(() => {
const now = new Date();
(useStatefulRowRenderer as jest.Mock).mockReturnValue({ (useStatefulRowRenderer as jest.Mock).mockReturnValue({
canShowRowRenderer: true, canShowRowRenderer: true,
}); });
(useDeepEqualSelector as jest.Mock).mockReturnValue({
noteId1: {
created: now,
eventId: 'event1',
id: 'test',
lastEdit: now,
note: 'note',
user: 'test',
saveObjectId: 'id',
timelineId: 'timeline-1',
version: '',
},
noteId2: {
created: now,
eventId: 'event1',
id: 'test',
lastEdit: now,
note: 'note',
user: 'test',
saveObjectId: 'id',
timelineId: 'timeline-1',
version: '',
},
});
}); });
afterEach(() => { afterEach(() => {
@ -87,4 +145,18 @@ describe('CustomTimelineDataGridBody', () => {
expect(queryByText('Cell-0-3')).toBeFalsy(); expect(queryByText('Cell-0-3')).toBeFalsy();
expect(getByText('Cell-1-3')).toBeInTheDocument(); expect(getByText('Cell-1-3')).toBeInTheDocument();
}); });
it('should render a note when notes are present', () => {
const { getByText } = renderTestComponents();
expect(getByText('note')).toBeInTheDocument();
});
it('should render the note creation form when the set of eventIds adding a note includes the eventId', () => {
const { getByTestId } = renderTestComponents({
...defaultProps,
eventIdsAddingNotes: new Set(['event1']),
});
expect(getByTestId('new-note-tabs')).toBeInTheDocument();
});
}); });

View file

@ -10,9 +10,16 @@ import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { EuiTheme } from '@kbn/react-kibana-context-styled'; import type { EuiTheme } from '@kbn/react-kibana-context-styled';
import type { TimelineItem } from '@kbn/timelines-plugin/common'; import type { TimelineItem } from '@kbn/timelines-plugin/common';
import type { FC } from 'react'; import type { FC } from 'react';
import React, { memo, useMemo } from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import type { RowRenderer } from '../../../../../../common/types'; import type { RowRenderer } from '../../../../../../common/types';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { appSelectors } from '../../../../../common/store';
import { TimelineId } from '../../../../../../common/types/timeline';
import { timelineActions } from '../../../../store';
import { NoteCards } from '../../../notes/note_cards';
import type { TimelineResultNote } from '../../../open_timeline/types';
import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants';
import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer';
import { useGetEventTypeRowClassName } from './use_get_event_type_row_classname'; import { useGetEventTypeRowClassName } from './use_get_event_type_row_classname';
@ -20,8 +27,15 @@ import { useGetEventTypeRowClassName } from './use_get_event_type_row_classname'
export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & { export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & {
rows: Array<DataTableRecord & TimelineItem> | undefined; rows: Array<DataTableRecord & TimelineItem> | undefined;
enabledRowRenderers: RowRenderer[]; enabledRowRenderers: RowRenderer[];
events: TimelineItem[];
eventIdToNoteIds?: Record<string, string[]> | null;
eventIdsAddingNotes?: Set<string>;
onToggleShowNotes: (eventId?: string) => void;
refetch?: () => void;
}; };
const emptyNotes: string[] = [];
/** /**
* *
* In order to render the additional row with every event ( which displays the row-renderer, notes and notes editor) * In order to render the additional row with every event ( which displays the row-renderer, notes and notes editor)
@ -37,25 +51,63 @@ export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & {
* */ * */
export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = memo( export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = memo(
function CustomTimelineDataGridBody(props) { function CustomTimelineDataGridBody(props) {
const { Cell, visibleColumns, visibleRowData, rows, enabledRowRenderers } = props; const {
Cell,
visibleColumns,
visibleRowData,
rows,
enabledRowRenderers,
events = [],
eventIdToNoteIds = {},
eventIdsAddingNotes = new Set<string>(),
onToggleShowNotes,
refetch,
} = props;
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
const notesById = useDeepEqualSelector(getNotesByIds);
const visibleRows = useMemo( const visibleRows = useMemo(
() => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow), () => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow),
[rows, visibleRowData] [rows, visibleRowData]
); );
const eventIds = useMemo(() => events.map((event) => event._id), [events]);
return ( return (
<> <>
{visibleRows.map((row, rowIndex) => ( {visibleRows.map((row, rowIndex) => {
<CustomDataGridSingleRow const eventId = eventIds[rowIndex];
rowData={row} const noteIds: string[] = (eventIdToNoteIds && eventIdToNoteIds[eventId]) || emptyNotes;
rowIndex={rowIndex} const notes = noteIds
key={rowIndex} .map((noteId) => {
visibleColumns={visibleColumns} const note = notesById[noteId];
Cell={Cell} if (note) {
enabledRowRenderers={enabledRowRenderers} return {
/> savedObjectId: note.saveObjectId,
))} note: note.note,
noteId: note.id,
updated: (note.lastEdit ?? note.created).getTime(),
updatedBy: note.user,
};
} else {
return null;
}
})
.filter((note) => note !== null) as TimelineResultNote[];
return (
<CustomDataGridSingleRow
rowData={row}
rowIndex={rowIndex}
key={rowIndex}
visibleColumns={visibleColumns}
Cell={Cell}
enabledRowRenderers={enabledRowRenderers}
notes={notes}
eventIdsAddingNotes={eventIdsAddingNotes}
eventId={eventId}
onToggleShowNotes={onToggleShowNotes}
refetch={refetch}
/>
);
})}
</> </>
); );
} }
@ -74,6 +126,29 @@ const CustomGridRow = styled.div.attrs<{
}))` }))`
width: fit-content; width: fit-content;
border-bottom: 1px solid ${(props) => (props.theme as EuiTheme).eui.euiBorderThin}; border-bottom: 1px solid ${(props) => (props.theme as EuiTheme).eui.euiBorderThin};
. euiDataGridRowCell--controlColumn {
height: 40px;
}
.udt--customRow {
border-radius: 0;
padding: ${(props) => (props.theme as EuiTheme).eui.euiDataGridCellPaddingM};
max-width: ${(props) => (props.theme as EuiTheme).eui.euiPageDefaultMaxWidth};
width: 85vw;
}
.euiCommentEvent__body {
background-color: ${(props) => (props.theme as EuiTheme).eui.euiColorEmptyShade};
}
&:has(.unifiedDataTable__cell--expanded) {
.euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn,
.euiDataGridRowCell--controlColumn,
.udt--customRow {
${({ theme }) => `background-color: ${theme.eui.euiColorHighlight};`}
}
}
}
`; `;
/* below styles as per : https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer */ /* below styles as per : https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer */
@ -84,12 +159,31 @@ const CustomGridRowCellWrapper = styled.div.attrs<{
role: 'row', role: 'row',
}))` }))`
display: flex; display: flex;
align-items: center;
height: 36px;
.euiDataGridRowCell,
.euiDataGridRowCell__content {
height: 100%;
.unifiedDataTable__rowControl {
margin-top: 0;
}
}
.euiDataGridRowCell--controlColumn .euiDataGridRowCell__content {
padding: 0;
}
`; `;
type CustomTimelineDataGridSingleRowProps = { type CustomTimelineDataGridSingleRowProps = {
rowData: DataTableRecord & TimelineItem; rowData: DataTableRecord & TimelineItem;
rowIndex: number; rowIndex: number;
} & Pick<CustomTimelineDataGridBodyProps, 'visibleColumns' | 'Cell' | 'enabledRowRenderers'>; notes?: TimelineResultNote[] | null;
eventId?: string;
eventIdsAddingNotes?: Set<string>;
onToggleShowNotes: (eventId?: string) => void;
} & Pick<
CustomTimelineDataGridBodyProps,
'visibleColumns' | 'Cell' | 'enabledRowRenderers' | 'refetch'
>;
/** /**
* *
@ -99,8 +193,19 @@ type CustomTimelineDataGridSingleRowProps = {
const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
props: CustomTimelineDataGridSingleRowProps props: CustomTimelineDataGridSingleRowProps
) { ) {
const { rowIndex, rowData, enabledRowRenderers, visibleColumns, Cell } = props; const {
rowIndex,
rowData,
enabledRowRenderers,
visibleColumns,
Cell,
notes,
eventIdsAddingNotes,
eventId = '',
onToggleShowNotes,
refetch,
} = props;
const dispatch = useDispatch();
const { canShowRowRenderer } = useStatefulRowRenderer({ const { canShowRowRenderer } = useStatefulRowRenderer({
data: rowData.ecs, data: rowData.ecs,
rowRenderers: enabledRowRenderers, rowRenderers: enabledRowRenderers,
@ -122,6 +227,26 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
); );
const eventTypeRowClassName = useGetEventTypeRowClassName(rowData.ecs); const eventTypeRowClassName = useGetEventTypeRowClassName(rowData.ecs);
const associateNote = useCallback(
(noteId: string) => {
dispatch(
timelineActions.addNoteToEvent({
eventId,
id: TimelineId.active,
noteId,
})
);
if (refetch) {
refetch();
}
},
[dispatch, eventId, refetch]
);
const renderNotesContainer = useMemo(() => {
return ((notes && notes.length > 0) || eventIdsAddingNotes?.has(eventId)) ?? false;
}, [notes, eventIdsAddingNotes, eventId]);
return ( return (
<CustomGridRow <CustomGridRow
className={`${rowIndex % 2 === 0 ? 'euiDataGridRow--striped' : ''}`} className={`${rowIndex % 2 === 0 ? 'euiDataGridRow--striped' : ''}`}
@ -143,6 +268,19 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
return null; return null;
})} })}
</CustomGridRowCellWrapper> </CustomGridRowCellWrapper>
{renderNotesContainer && (
<NoteCards
ariaRowindex={rowIndex}
associateNote={associateNote}
className="udt--customRow"
data-test-subj="note-cards"
notes={notes ?? []}
showAddNote={eventIdsAddingNotes?.has(eventId) ?? false}
toggleShowAddNote={onToggleShowNotes}
eventId={eventId}
/>
)}
{/* Timeline Expanded Row */} {/* Timeline Expanded Row */}
{canShowRowRenderer ? ( {canShowRowRenderer ? (
<Cell <Cell

View file

@ -54,6 +54,7 @@ const TestComponent = (props: TestComponentProps) => {
itemsPerPage={50} itemsPerPage={50}
itemsPerPageOptions={[10, 25, 50, 100]} itemsPerPageOptions={[10, 25, 50, 100]}
rowRenderers={[]} rowRenderers={[]}
leadingControlColumns={[]}
sort={[['@timestamp', 'desc']]} sort={[['@timestamp', 'desc']]}
events={mockTimelineData} events={mockTimelineData}
onFieldEdited={onFieldEditedMock} onFieldEdited={onFieldEditedMock}

View file

@ -68,7 +68,19 @@ type CommonDataTableProps = {
dataLoadingState: DataLoadingState; dataLoadingState: DataLoadingState;
updatedAt: number; updatedAt: number;
isTextBasedQuery?: boolean; isTextBasedQuery?: boolean;
} & Pick<UnifiedDataTableProps, 'onSort' | 'onSetColumns' | 'sort' | 'onFilter' | 'isSortEnabled'>; leadingControlColumns: EuiDataGridProps['leadingControlColumns'];
cellContext?: EuiDataGridProps['cellContext'];
eventIdToNoteIds?: Record<string, string[]>;
} & Pick<
UnifiedDataTableProps,
| 'onSort'
| 'onSetColumns'
| 'sort'
| 'onFilter'
| 'renderCustomGridBody'
| 'trailingControlColumns'
| 'isSortEnabled'
>;
interface DataTableProps extends CommonDataTableProps { interface DataTableProps extends CommonDataTableProps {
dataView: DataView; dataView: DataView;
@ -100,6 +112,9 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
onSetColumns, onSetColumns,
onSort, onSort,
onFilter, onFilter,
leadingControlColumns,
cellContext,
eventIdToNoteIds,
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -369,11 +384,24 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
Cell={Cell} Cell={Cell}
visibleColumns={visibleColumns} visibleColumns={visibleColumns}
visibleRowData={visibleRowData} visibleRowData={visibleRowData}
eventIdToNoteIds={eventIdToNoteIds}
setCustomGridBodyProps={setCustomGridBodyProps} setCustomGridBodyProps={setCustomGridBodyProps}
events={events}
enabledRowRenderers={enabledRowRenderers} enabledRowRenderers={enabledRowRenderers}
eventIdsAddingNotes={cellContext?.eventIdsAddingNotes}
onToggleShowNotes={cellContext?.onToggleShowNotes}
refetch={refetch}
/> />
), ),
[tableRows, enabledRowRenderers] [
tableRows,
enabledRowRenderers,
events,
eventIdToNoteIds,
cellContext?.eventIdsAddingNotes,
cellContext?.onToggleShowNotes,
refetch,
]
); );
return ( return (
@ -424,8 +452,10 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
showMultiFields={true} showMultiFields={true}
cellActionsMetadata={cellActionsMetadata} cellActionsMetadata={cellActionsMetadata}
externalAdditionalControls={additionalControls} externalAdditionalControls={additionalControls}
trailingControlColumns={trailingControlColumns}
renderCustomGridBody={renderCustomBodyCallback} renderCustomGridBody={renderCustomBodyCallback}
trailingControlColumns={trailingControlColumns}
externalControlColumns={leadingControlColumns}
cellContext={cellContext}
/> />
{showExpandedDetails && !isTimelineExpandableFlyoutEnabled && ( {showExpandedDetails && !isTimelineExpandableFlyoutEnabled && (
<DetailsPanel <DetailsPanel

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import type { EuiDataGridProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui';
import React, { useMemo, useCallback, useState, useRef } from 'react'; import React, { useMemo, useCallback, useState, useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@ -46,6 +47,7 @@ import { DRAG_DROP_FIELD } from './data_table/translations';
import { TimelineResizableLayout } from './resizable_layout'; import { TimelineResizableLayout } from './resizable_layout';
import TimelineDataTable from './data_table'; import TimelineDataTable from './data_table';
import { timelineActions } from '../../../store'; import { timelineActions } from '../../../store';
import type { TimelineModel } from '../../../store/model';
import { getFieldsListCreationOptions } from './get_fields_list_creation_options'; import { getFieldsListCreationOptions } from './get_fields_list_creation_options';
import { defaultUdtHeaders } from './default_headers'; import { defaultUdtHeaders } from './default_headers';
@ -115,6 +117,10 @@ interface Props {
updatedAt: number; updatedAt: number;
isTextBasedQuery?: boolean; isTextBasedQuery?: boolean;
dataView: DataView; dataView: DataView;
trailingControlColumns?: EuiDataGridProps['trailingControlColumns'];
leadingControlColumns?: EuiDataGridProps['leadingControlColumns'];
pinnedEventIds?: TimelineModel['pinnedEventIds'];
eventIdToNoteIds?: TimelineModel['eventIdToNoteIds'];
} }
const UnifiedTimelineComponent: React.FC<Props> = ({ const UnifiedTimelineComponent: React.FC<Props> = ({
@ -137,6 +143,10 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
updatedAt, updatedAt,
isTextBasedQuery, isTextBasedQuery,
dataView, dataView,
trailingControlColumns,
leadingControlColumns,
pinnedEventIds,
eventIdToNoteIds,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null); const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
@ -156,7 +166,19 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
timelineFilterManager, timelineFilterManager,
}, },
} = useKibana(); } = useKibana();
const [eventIdsAddingNotes, setEventIdsAddingNotes] = useState<Set<string>>(new Set());
const onToggleShowNotes = useCallback(
(eventId: string) => {
const newSet = new Set(eventIdsAddingNotes);
if (newSet.has(eventId)) {
newSet.delete(eventId);
setEventIdsAddingNotes(newSet);
} else {
setEventIdsAddingNotes(newSet.add(eventId));
}
},
[eventIdsAddingNotes]
);
const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo( const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
() => ({ () => ({
fieldFormats, fieldFormats,
@ -344,6 +366,17 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
onFieldEdited(); onFieldEdited();
}, [onFieldEdited]); }, [onFieldEdited]);
const cellContext = useMemo(() => {
return {
events,
pinnedEventIds,
eventIdsAddingNotes,
onToggleShowNotes,
eventIdToNoteIds,
refetch,
};
}, [events, pinnedEventIds, eventIdsAddingNotes, onToggleShowNotes, eventIdToNoteIds, refetch]);
return ( return (
<TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}> <TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}>
<TimelineResizableLayout <TimelineResizableLayout
@ -418,6 +451,10 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
updatedAt={updatedAt} updatedAt={updatedAt}
isTextBasedQuery={isTextBasedQuery} isTextBasedQuery={isTextBasedQuery}
onFilter={onAddFilter as DocViewFilterFn} onFilter={onAddFilter as DocViewFilterFn}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
cellContext={cellContext}
eventIdToNoteIds={eventIdToNoteIds}
/> />
</EventDetailsWidthProvider> </EventDetailsWidthProvider>
</DropOverlayWrapper> </DropOverlayWrapper>

View file

@ -67,6 +67,11 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
margin-top: 3px; margin-top: 3px;
} }
.udtTimeline .euiDataGridHeaderCell.euiDataGridHeaderCell--controlColumn {
padding: 0;
position: relative;
}
.udtTimeline .euiDataGridRowCell--controlColumn { .udtTimeline .euiDataGridRowCell--controlColumn {
overflow: visible; overflow: visible;
} }
@ -100,8 +105,9 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
} }
.udtTimeline .euiDataGridRow:has(.eqlSequence) { .udtTimeline .euiDataGridRow:has(.eqlSequence) {
.euiDataGridRowCell--firstColumn, .euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn { .euiDataGridRowCell--lastColumn,
${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorPrimary};`} .udt--customRow {
${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorPrimary}`};
} }
background: repeating-linear-gradient( background: repeating-linear-gradient(
127deg, 127deg,
@ -113,7 +119,8 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
} }
.udtTimeline .euiDataGridRow:has(.eqlNonSequence) { .udtTimeline .euiDataGridRow:has(.eqlNonSequence) {
.euiDataGridRowCell--firstColumn, .euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn { .euiDataGridRowCell--lastColumn,
.udt--customRow {
${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorAccent};`} ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorAccent};`}
} }
background: repeating-linear-gradient( background: repeating-linear-gradient(
@ -126,13 +133,15 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
} }
.udtTimeline .euiDataGridRow:has(.nonRawEvent) { .udtTimeline .euiDataGridRow:has(.nonRawEvent) {
.euiDataGridRowCell--firstColumn, .euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn { .euiDataGridRowCell--lastColumn,
.udt--customRow {
${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorWarning};`} ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorWarning};`}
} }
} }
.udtTimeline .euiDataGridRow:has(.rawEvent) { .udtTimeline .euiDataGridRow:has(.rawEvent) {
.euiDataGridRowCell--firstColumn, .euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn { .euiDataGridRowCell--lastColumn,
.udt--customRow {
${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorLightShade};`} ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorLightShade};`}
} }
} }

View file

@ -17,6 +17,12 @@ import {
TIMELINE_DETAILS_FLYOUT, TIMELINE_DETAILS_FLYOUT,
USER_DETAILS_FLYOUT, USER_DETAILS_FLYOUT,
} from '../../../../screens/unified_timeline'; } from '../../../../screens/unified_timeline';
import {
ROW_ADD_NOTES_BUTTON,
ADD_NOTE_CONTAINER,
RESOLVER_GRAPH_CONTAINER,
} from '../../../../screens/timeline';
import { OPEN_ANALYZER_BTN } from '../../../../screens/alerts';
import { GET_DISCOVER_DATA_GRID_CELL_HEADER } from '../../../../screens/discover'; import { GET_DISCOVER_DATA_GRID_CELL_HEADER } from '../../../../screens/discover';
import { addFieldToTable, removeFieldFromTable } from '../../../../tasks/discover'; import { addFieldToTable, removeFieldFromTable } from '../../../../tasks/discover';
import { login } from '../../../../tasks/login'; import { login } from '../../../../tasks/login';
@ -57,6 +63,16 @@ describe.skip(
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('agent.type')).should('not.exist'); cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('agent.type')).should('not.exist');
}); });
it('should render the add note button and display the markdown editor', () => {
cy.get(ROW_ADD_NOTES_BUTTON).should('be.visible').realClick();
cy.get(ADD_NOTE_CONTAINER).should('be.visible');
});
it('should render the analyze event button and display the process analyzer visualization', () => {
cy.get(OPEN_ANALYZER_BTN).should('be.visible').realClick();
cy.get(RESOLVER_GRAPH_CONTAINER).should('be.visible');
});
// these tests are skipped until we implement the expandable flyout in the unified table for timeline // these tests are skipped until we implement the expandable flyout in the unified table for timeline
context('flyout', () => { context('flyout', () => {
it.skip('should be able to open/close details details/host/user flyout', () => { it.skip('should be able to open/close details details/host/user flyout', () => {

View file

@ -61,6 +61,10 @@ export const UNLOCKED_ICON = '[data-test-subj="timeline-date-picker-unlock-butto
export const ROW_ADD_NOTES_BUTTON = '[data-test-subj="timeline-notes-button-small"]'; export const ROW_ADD_NOTES_BUTTON = '[data-test-subj="timeline-notes-button-small"]';
export const ADD_NOTE_CONTAINER = '[data-test-subj="add-note-container"]';
export const RESOLVER_GRAPH_CONTAINER = '[data-test-subj="resolver:graph"]';
export const NOTE_CARD_CONTENT = '[data-test-subj="notes"]'; export const NOTE_CARD_CONTENT = '[data-test-subj="notes"]';
export const NOTE_DESCRIPTION = '[data-test-subj="note-preview-description"]'; export const NOTE_DESCRIPTION = '[data-test-subj="note-preview-description"]';