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

View file

@ -5,10 +5,10 @@
* 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 { 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 { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
@ -66,16 +66,7 @@ export interface HeaderActionProps {
export type HeaderCellRender = ComponentType | ComponentType<HeaderActionProps>;
type GenericActionRowCellRenderProps = Pick<
EuiDataGridCellValueElementProps,
'rowIndex' | 'columnId'
>;
export type RowCellRender =
| JSXElementConstructor<GenericActionRowCellRenderProps>
| ((props: GenericActionRowCellRenderProps) => JSX.Element)
| JSXElementConstructor<ActionProps>
| ((props: ActionProps) => JSX.Element);
export type RowCellRender = EuiDataGridProps['renderCellValue'];
export interface ActionProps {
action?: RowCellRender;

View file

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

View file

@ -14,10 +14,10 @@ import { getDefaultControlColumn } from '../../../../timelines/components/timeli
import { useIsExperimentalFeatureEnabled } from '../../../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;
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {

View file

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

View file

@ -68,6 +68,9 @@ const ActionsComponent: React.FC<ActionProps> = ({
}) => {
const dispatch = useDispatch();
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
const emptyNotes: string[] = [];
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timelineType = useShallowEqualSelector(
@ -224,6 +227,10 @@ const ActionsComponent: React.FC<ActionProps> = ({
}
onEventDetailsPanelOpened();
}, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]);
const showExpandEvent = useMemo(
() => !unifiedComponentsInTimelineEnabled || isEventViewer || timelineId !== TimelineId.active,
[isEventViewer, timelineId, unifiedComponentsInTimelineEnabled]
);
return (
<ActionsContainer>
@ -244,35 +251,38 @@ const ActionsComponent: React.FC<ActionProps> = ({
</EventsTdContent>
</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 && (
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE_FOR_ROW({ ariaRowindex, columnValues })}
key="investigate-in-timeline"
ecsRowData={ecsData}
/>
{showExpandEvent && (
<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 && (
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE_FOR_ROW({ ariaRowindex, columnValues })}
key="investigate-in-timeline"
ecsRowData={ecsData}
/>
)}
</>
{!isEventViewer && toggleShowNotes && (
<>
<AddEventNoteAction
@ -281,6 +291,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
showNotes={showNotes ?? false}
toggleShowNotes={toggleShowNotes}
timelineType={timelineType}
eventId={eventId}
/>
<PinEventAction
ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
*/
import { EuiBadge, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import type { TimelineTypeLiteral } from '../../../../../common/api/timeline';
@ -24,23 +24,32 @@ interface NotesButtonProps {
ariaLabel?: string;
isDisabled?: boolean;
showNotes: boolean;
toggleShowNotes: () => void;
toggleShowNotes: () => void | ((eventId: string) => void);
toolTip?: string;
timelineType: TimelineTypeLiteral;
eventId?: string;
}
interface SmallNotesButtonProps {
ariaLabel?: string;
isDisabled?: boolean;
toggleShowNotes: () => void;
toggleShowNotes: (eventId?: string) => void;
timelineType: TimelineTypeLiteral;
eventId?: string;
}
export const NOTES_BUTTON_CLASS_NAME = 'notes-button';
const SmallNotesButton = React.memo<SmallNotesButtonProps>(
({ ariaLabel = i18n.NOTES, isDisabled, toggleShowNotes, timelineType }) => {
({ ariaLabel = i18n.NOTES, isDisabled, toggleShowNotes, timelineType, eventId }) => {
const isTemplate = timelineType === TimelineType.template;
const onClick = useCallback(() => {
if (eventId != null) {
toggleShowNotes(eventId);
} else {
toggleShowNotes();
}
}, [toggleShowNotes, eventId]);
return (
<EuiButtonIcon
@ -49,7 +58,7 @@ const SmallNotesButton = React.memo<SmallNotesButtonProps>(
data-test-subj="timeline-notes-button-small"
disabled={isDisabled}
iconType="editorComment"
onClick={toggleShowNotes}
onClick={onClick}
size="s"
isDisabled={isTemplate}
/>
@ -59,13 +68,14 @@ const SmallNotesButton = React.memo<SmallNotesButtonProps>(
SmallNotesButton.displayName = 'SmallNotesButton';
export const NotesButton = React.memo<NotesButtonProps>(
({ ariaLabel, isDisabled, showNotes, timelineType, toggleShowNotes, toolTip }) =>
({ ariaLabel, isDisabled, showNotes, timelineType, toggleShowNotes, toolTip, eventId }) =>
showNotes ? (
<SmallNotesButton
ariaLabel={ariaLabel}
isDisabled={isDisabled}
toggleShowNotes={toggleShowNotes}
timelineType={timelineType}
eventId={eventId}
/>
) : (
<EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip">
@ -74,6 +84,7 @@ export const NotesButton = React.memo<NotesButtonProps>(
isDisabled={isDisabled}
toggleShowNotes={toggleShowNotes}
timelineType={timelineType}
eventId={eventId}
/>
</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"
eqlOptions={Object {}}
eventIdToNoteIds={Object {}}
expandedDetail={Object {}}
isLive={false}
itemsPerPage={5}
@ -170,6 +171,7 @@ In other use cases the message field can be used to concatenate different values
]
}
onEventClosed={[MockFunction]}
pinnedEventIds={Object {}}
renderCellValue={[Function]}
rowRenderers={
Array [

View file

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

View file

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

View file

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

View file

@ -6,13 +6,13 @@
*/
import { isEmpty } from 'lodash/fp';
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, memo } from 'react';
import styled from 'styled-components';
import type { Dispatch } from 'redux';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import { DataLoadingState } from '@kbn/unified-data-table';
import type { ControlColumnProps } from '../../../../../../common/types';
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 { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { timelineDefaults } from '../../../../store/defaults';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useTimelineFullScreen } from '../../../../../common/containers/use_full_screen';
import type { TimelineModel } from '../../../../store/model';
@ -34,9 +35,7 @@ import type { ToggleDetailPanel } from '../../../../../../common/types/timeline'
import { TimelineTabs } from '../../../../../../common/types/timeline';
import { DetailsPanel } from '../../../side_panel';
import { ExitFullScreen } from '../../../../../common/components/exit_full_screen';
import { getDefaultControlColumn } from '../../body/control_columns';
import { useLicense } from '../../../../../common/hooks/use_license';
import { HeaderActions } from '../../../../../common/components/header_actions/header_actions';
import { UnifiedTimelineBody } from '../../body/unified_timeline_body';
import {
FullWidthFlexGroup,
ScrollableFlexItem,
@ -45,10 +44,13 @@ import {
VerticalRule,
} from '../shared/layout';
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`
width: 180px;
`;
interface PinnedFilter {
bool: {
should: Array<{ match_phrase: { _id: string } }>;
@ -60,6 +62,16 @@ export type Props = TimelineTabCommonProps & PropsFromRedux;
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> = ({
columns,
timelineId,
@ -71,6 +83,8 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
rowRenderers,
showExpandedDetails,
sort,
expandedDetail,
eventIdToNoteIds,
}) => {
const {
browserFields,
@ -80,8 +94,9 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline);
const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen();
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
const filterQuery = useMemo(() => {
if (isEmpty(pinnedEventIds)) {
@ -138,6 +153,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
})),
[sort]
);
const { augmentedColumnHeaders } = useTimelineColumns(columns);
const [queryLoadingState, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] =
useTimelineEvents({
@ -155,6 +171,8 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
timerangeKind: undefined,
});
const leadingControlColumns = useTimelineControlColumn(columns, sort);
const isQueryLoading = useMemo(
() => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(queryLoadingState),
[queryLoadingState]
@ -164,14 +182,35 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
onEventClosed({ tabType: TimelineTabs.pinned, id: timelineId });
}, [timelineId, onEventClosed]);
const leadingControlColumns = useMemo(
() =>
getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
...x,
headerCellRender: HeaderActions,
})),
[ACTION_BUTTON_COUNT]
);
if (unifiedComponentsInTimelineEnabled) {
return (
<UnifiedTimelineBody
header={<></>}
columns={augmentedColumnHeaders}
rowRenderers={rowRenderers}
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 (
<>
@ -204,7 +243,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
itemsCount: totalCount,
itemsPerPage,
})}
leadingControlColumns={leadingControlColumns}
leadingControlColumns={leadingControlColumns as ControlColumnProps[]}
trailingControlColumns={trailingControlColumns}
/>
</StyledEuiFlyoutBody>
@ -252,8 +291,15 @@ const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const mapStateToProps = (state: State, { timelineId }: TimelineTabCommonProps) => {
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const { columns, expandedDetail, itemsPerPage, itemsPerPageOptions, pinnedEventIds, sort } =
timeline;
const {
columns,
expandedDetail,
itemsPerPage,
itemsPerPageOptions,
pinnedEventIds,
sort,
eventIdToNoteIds,
} = timeline;
return {
columns,
@ -264,6 +310,8 @@ const makeMapStateToProps = () => {
showExpandedDetails:
!!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView,
sort,
expandedDetail,
eventIdToNoteIds,
};
};
return mapStateToProps;
@ -280,7 +328,7 @@ const connector = connect(makeMapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
const PinnedTabContent = connector(
React.memo(
memo(
PinnedTabContentComponent,
(prevProps, nextProps) =>
prevProps.itemsPerPage === nextProps.itemsPerPage &&
@ -288,6 +336,7 @@ const PinnedTabContent = connector(
prevProps.showExpandedDetails === nextProps.showExpandedDetails &&
prevProps.timelineId === nextProps.timelineId &&
deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.eventIdToNoteIds, nextProps.eventIdToNoteIds) &&
deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) &&
deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) &&
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"
eventIdToNoteIds={Object {}}
expandedDetail={Object {}}
filters={Array []}
isLive={false}
@ -307,6 +308,7 @@ In other use cases the message field can be used to concatenate different values
kqlQueryExpression=" "
kqlQueryLanguage="kuery"
onEventClosed={[MockFunction]}
pinnedEventIds={Object {}}
renderCellValue={[Function]}
rowRenderers={
Array [

View file

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

View file

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

View file

@ -472,26 +472,3 @@ Array [
"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 { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { renderHook } from '@testing-library/react-hooks';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useTimelineColumns } from './use_timeline_columns';
import { defaultUdtHeaders } from '../../unified_components/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;
jest.mock('../../../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({
isEnterprise: () => true,
}),
}));
const useLicenseMock = useLicense as jest.Mock;
describe('useTimelineColumns', () => {
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', () => {
it('should return the list of all the fields', () => {
const { result } = renderHook(() => useTimelineColumns([]), {
wrapper: TestProviders,
});
expect(result.current.getTimelineQueryFieldsFromColumns()).toMatchSnapshot();
expect(result.current.timelineQueryFieldsFromColumns).toMatchSnapshot();
});
it('should have a width of 152 for 5 actions', () => {
const { result } = renderHook(() => useTimelineColumns(mockColumns), {
wrapper: TestProviders,
});
expect(result.current.getTimelineQueryFieldsFromColumns()).toMatchSnapshot();
expect(result.current.timelineQueryFieldsFromColumns).toMatchSnapshot();
});
});
});

View file

@ -6,15 +6,12 @@
*/
import { isEmpty } from 'lodash/fp';
import { useCallback, useMemo } from 'react';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useMemo } from 'react';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { defaultHeaders } from '../../body/column_headers/default_headers';
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 type { ColumnHeaderOptions } from '../../../../../../common/types';
import { memoizedGetTimelineColumnHeaders } from './utils';
@ -26,9 +23,6 @@ export const useTimelineColumns = (columns: ColumnHeaderOptions[]) => {
'unifiedComponentsInTimelineEnabled'
);
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
const defaultColumns = useMemo(
() => (unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders),
[unifiedComponentsInTimelineEnabled]
@ -45,35 +39,19 @@ export const useTimelineColumns = (columns: ColumnHeaderOptions[]) => {
false
);
const getTimelineQueryFieldsFromColumns = useCallback(() => {
const timelineQueryFieldsFromColumns = useMemo(() => {
const columnFields = augmentedColumnHeaders.map((c) => c.id);
return [...columnFields, ...requiredFieldsForActions];
}, [augmentedColumnHeaders]);
const leadingControlColumns = useMemo(
() =>
getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
...x,
headerCellRender: HeaderActions,
})),
[ACTION_BUTTON_COUNT]
);
return useMemo(
() => ({
defaultColumns,
localColumns,
augmentedColumnHeaders,
getTimelineQueryFieldsFromColumns,
leadingControlColumns,
timelineQueryFieldsFromColumns,
}),
[
augmentedColumnHeaders,
defaultColumns,
getTimelineQueryFieldsFromColumns,
leadingControlColumns,
localColumns,
]
[augmentedColumnHeaders, defaultColumns, timelineQueryFieldsFromColumns, 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
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 {
width: -webkit-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;
}
.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 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
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>
@ -34,6 +93,140 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
Cell-0-2
</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>
Cell-0-3
</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 { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer';
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);
@ -37,6 +40,8 @@ const mockVisibleColumns = ['@timestamp', 'message', 'user.name']
.map((id) => defaultUdtHeaders.find((h) => h.id === id) as EuiDataGridColumn)
.concat(additionalTrailingColumn);
const mockEventIdsAddingNotes = new Set<string>();
const defaultProps: CustomTimelineDataGridBodyProps = {
Cell: MockCellComponent,
visibleRowData: { startRow: 0, endRow: 2, visibleRowCount: 2 },
@ -44,6 +49,34 @@ const defaultProps: CustomTimelineDataGridBodyProps = {
enabledRowRenderers: [],
setCustomGridBodyProps: jest.fn(),
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) => {
@ -58,9 +91,34 @@ const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => {
describe('CustomTimelineDataGridBody', () => {
beforeEach(() => {
const now = new Date();
(useStatefulRowRenderer as jest.Mock).mockReturnValue({
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(() => {
@ -87,4 +145,18 @@ describe('CustomTimelineDataGridBody', () => {
expect(queryByText('Cell-0-3')).toBeFalsy();
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 { TimelineItem } from '@kbn/timelines-plugin/common';
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 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 { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer';
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 & {
rows: Array<DataTableRecord & TimelineItem> | undefined;
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)
@ -37,25 +51,63 @@ export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & {
* */
export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = memo(
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(
() => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow),
[rows, visibleRowData]
);
const eventIds = useMemo(() => events.map((event) => event._id), [events]);
return (
<>
{visibleRows.map((row, rowIndex) => (
<CustomDataGridSingleRow
rowData={row}
rowIndex={rowIndex}
key={rowIndex}
visibleColumns={visibleColumns}
Cell={Cell}
enabledRowRenderers={enabledRowRenderers}
/>
))}
{visibleRows.map((row, rowIndex) => {
const eventId = eventIds[rowIndex];
const noteIds: string[] = (eventIdToNoteIds && eventIdToNoteIds[eventId]) || emptyNotes;
const notes = noteIds
.map((noteId) => {
const note = notesById[noteId];
if (note) {
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;
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 */
@ -84,12 +159,31 @@ const CustomGridRowCellWrapper = styled.div.attrs<{
role: 'row',
}))`
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 = {
rowData: DataTableRecord & TimelineItem;
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(
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({
data: rowData.ecs,
rowRenderers: enabledRowRenderers,
@ -122,6 +227,26 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
);
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 (
<CustomGridRow
className={`${rowIndex % 2 === 0 ? 'euiDataGridRow--striped' : ''}`}
@ -143,6 +268,19 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
return null;
})}
</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 */}
{canShowRowRenderer ? (
<Cell

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,12 @@ import {
TIMELINE_DETAILS_FLYOUT,
USER_DETAILS_FLYOUT,
} 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 { addFieldToTable, removeFieldFromTable } from '../../../../tasks/discover';
import { login } from '../../../../tasks/login';
@ -57,6 +63,16 @@ describe.skip(
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
context('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 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_DESCRIPTION = '[data-test-subj="note-preview-description"]';