[SECURITY] Timeline bug 7.9 (#71748)

* remove delay of rendering row

* Fix flyout timeline to behave as we wanted

* Fix tabs on timeline page

* disable sensor visibility when you have less than 100 events in timeline

* Fix container to fit content and not take all the place that it wants

* do not update timeline time when switching top nav

* fix timeline url in case

* review I

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Xavier Mouligneau 2020-07-15 03:51:31 -04:00 committed by GitHub
parent 667b72f9e8
commit 75582eb4ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 245 additions and 200 deletions

View file

@ -8,7 +8,6 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
import React, { useCallback, useEffect } from 'react';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import { CommentRequest } from '../../../../../case/common/api';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
@ -19,12 +18,7 @@ import { Form, useForm, UseField } from '../../../shared_imports';
import * as i18n from './translations';
import { schema } from './schema';
import {
dispatchUpdateTimeline,
queryTimelineById,
} from '../../../timelines/components/open_timeline/helpers';
import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions';
import { useApolloClient } from '../../../common/utils/apollo_context';
import { useTimelineClick } from '../utils/use_timeline_click';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@ -53,8 +47,7 @@ export const AddComment = React.memo<AddCommentProps>(
options: { stripEmptyFields: false },
schema,
});
const dispatch = useDispatch();
const apolloClient = useApolloClient();
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CommentRequest>(
form,
'comment'
@ -68,30 +61,9 @@ export const AddComment = React.memo<AddCommentProps>(
`${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}`
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertQuote]);
}, [form, insertQuote]);
const handleTimelineClick = useCallback(
(timelineId: string) => {
queryTimelineById({
apolloClient,
timelineId,
updateIsLoading: ({
id: currentTimelineId,
isLoading: isLoadingTimeline,
}: {
id: string;
isLoading: boolean;
}) =>
dispatch(
dispatchUpdateIsLoading({ id: currentTimelineId, isLoading: isLoadingTimeline })
),
updateTimeline: dispatchUpdateTimeline(dispatch),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[apolloClient]
);
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
@ -102,8 +74,8 @@ export const AddComment = React.memo<AddCommentProps>(
postComment(data, onCommentPosted);
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form, onCommentPosted, onCommentSaving]);
}, [form, onCommentPosted, onCommentSaving, postComment]);
return (
<span id="add-comment-permLink">
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}

View file

@ -29,14 +29,6 @@ const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useHistory: jest.fn(),
};
});
jest.mock('../../../common/components/link_to');
describe('AllCases', () => {

View file

@ -5,7 +5,6 @@
*/
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
EuiBasicTable,
EuiContextMenuPanel,
@ -50,6 +49,8 @@ import { ConfigureCaseButton } from '../configure_cases/button';
import { ERROR_PUSH_SERVICE_CALLOUT_TITLE } from '../use_push_to_service/translations';
import { LinkButton } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
import { useKibana } from '../../../common/lib/kibana';
import { APP_ID } from '../../../../common/constants';
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
@ -81,13 +82,13 @@ const getSortField = (field: string): SortFieldCase => {
};
interface AllCasesProps {
onRowClick?: (id: string) => void;
onRowClick?: (id?: string) => void;
isModal?: boolean;
userCanCrud: boolean;
}
export const AllCases = React.memo<AllCasesProps>(
({ onRowClick = () => {}, isModal = false, userCanCrud }) => {
const history = useHistory();
({ onRowClick, isModal = false, userCanCrud }) => {
const { navigateToApp } = useKibana().services.application;
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case);
const { actionLicense } = useGetActionLicense();
const {
@ -234,9 +235,15 @@ export const AllCases = React.memo<AllCasesProps>(
const goToCreateCase = useCallback(
(ev) => {
ev.preventDefault();
history.push(getCreateCaseUrl(urlSearch));
if (isModal && onRowClick != null) {
onRowClick();
} else {
navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: getCreateCaseUrl(urlSearch),
});
}
},
[history, urlSearch]
[navigateToApp, isModal, onRowClick, urlSearch]
);
const actions = useMemo(
@ -445,7 +452,11 @@ export const AllCases = React.memo<AllCasesProps>(
rowProps={(item) =>
isModal
? {
onClick: () => onRowClick(item.id),
onClick: () => {
if (onRowClick != null) {
onRowClick(item.id);
}
},
}
: {}
}

View file

@ -19,7 +19,7 @@ import * as i18n from './translations';
interface AllCasesModalProps {
onCloseCaseModal: () => void;
showCaseModal: boolean;
onRowClick: (id: string) => void;
onRowClick: (id?: string) => void;
}
export const AllCasesModalComponent = ({

View file

@ -33,6 +33,7 @@ import * as i18n from '../../translations';
import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form';
import { useGetTags } from '../../containers/use_get_tags';
import { getCaseDetailsUrl } from '../../../common/components/link_to';
import { useTimelineClick } from '../utils/use_timeline_click';
export const CommonUseField = getUseField({ component: Field });
@ -87,6 +88,7 @@ export const Create = React.memo(() => {
form,
'description'
);
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
@ -94,8 +96,7 @@ export const Create = React.memo(() => {
// `postCase`'s type is incorrect, it actually returns a promise
await postCase(data);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [form, postCase]);
const handleSetIsCancel = useCallback(() => {
history.push('/');
@ -145,6 +146,7 @@ export const Create = React.memo(() => {
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
onClickTimeline: handleTimelineClick,
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
<InsertTimelinePopover

View file

@ -44,6 +44,7 @@ describe('UserActionMarkdown ', () => {
expect(queryTimelineByIdSpy).toBeCalledWith({
apolloClient: mockUseApolloClient(),
graphEventId: '',
timelineId,
updateIsLoading: expect.any(Function),
updateTimeline: expect.any(Function),
@ -62,6 +63,7 @@ describe('UserActionMarkdown ', () => {
wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click');
expect(queryTimelineByIdSpy).toBeCalledWith({
apolloClient: mockUseApolloClient(),
graphEventId: '',
timelineId,
updateIsLoading: expect.any(Function),
updateTimeline: expect.any(Function),

View file

@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/e
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
import { useDispatch } from 'react-redux';
import * as i18n from '../case_view/translations';
import { Markdown } from '../../../common/components/markdown';
import { Form, useForm, UseField } from '../../../shared_imports';
@ -16,13 +15,7 @@ import { schema, Content } from './schema';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form';
import {
dispatchUpdateTimeline,
queryTimelineById,
} from '../../../timelines/components/open_timeline/helpers';
import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions';
import { useApolloClient } from '../../../common/utils/apollo_context';
import { useTimelineClick } from '../utils/use_timeline_click';
const ContentWrapper = styled.div`
${({ theme }) => css`
@ -44,8 +37,6 @@ export const UserActionMarkdown = ({
onChangeEditable,
onSaveContent,
}: UserActionMarkdownProps) => {
const dispatch = useDispatch();
const apolloClient = useApolloClient();
const { form } = useForm<Content>({
defaultValue: { content },
options: { stripEmptyFields: false },
@ -59,24 +50,7 @@ export const UserActionMarkdown = ({
onChangeEditable(id);
}, [id, onChangeEditable]);
const handleTimelineClick = useCallback(
(timelineId: string) => {
queryTimelineById({
apolloClient,
timelineId,
updateIsLoading: ({
id: currentTimelineId,
isLoading,
}: {
id: string;
isLoading: boolean;
}) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })),
updateTimeline: dispatchUpdateTimeline(dispatch),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[apolloClient]
);
const handleTimelineClick = useTimelineClick();
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await form.submit();

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useApolloClient } from '../../../common/utils/apollo_context';
import {
dispatchUpdateTimeline,
queryTimelineById,
} from '../../../timelines/components/open_timeline/helpers';
import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions';
export const useTimelineClick = () => {
const dispatch = useDispatch();
const apolloClient = useApolloClient();
const handleTimelineClick = useCallback(
(timelineId: string, graphEventId?: string) => {
queryTimelineById({
apolloClient,
graphEventId,
timelineId,
updateIsLoading: ({
id: currentTimelineId,
isLoading,
}: {
id: string;
isLoading: boolean;
}) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })),
updateTimeline: dispatchUpdateTimeline(dispatch),
});
},
[apolloClient, dispatch]
);
return handleTimelineClick;
};

View file

@ -106,8 +106,7 @@ const EventsViewerComponent: React.FC<Props> = ({
useEffect(() => {
setIsTimelineLoading({ id, isLoading: isQueryLoading });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isQueryLoading]);
}, [id, isQueryLoading, setIsTimelineLoading]);
const { queryFields, title, unit } = useMemo(() => getManageTimelineById(id), [
getManageTimelineById,

View file

@ -157,7 +157,19 @@ describe('Markdown', () => {
);
wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click');
expect(onClickTimeline).toHaveBeenCalledWith(timelineId);
expect(onClickTimeline).toHaveBeenCalledWith(timelineId, '');
});
test('timeline link onClick calls onClickTimeline with timelineId and graphEventId', () => {
const graphEventId = '2bc51864784c';
const markdownWithTimelineAndGraphEventLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t,graphEventId:'${graphEventId}'))`;
const wrapper = mount(
<Markdown raw={markdownWithTimelineAndGraphEventLink} onClickTimeline={onClickTimeline} />
);
wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click');
expect(onClickTimeline).toHaveBeenCalledWith(timelineId, graphEventId);
});
});
});

View file

@ -7,6 +7,7 @@
/* eslint-disable react/display-name */
import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@elastic/eui';
import { clone } from 'lodash/fp';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import styled, { css } from 'styled-components';
@ -38,7 +39,7 @@ const REL_NOREFERRER = 'noreferrer';
export const Markdown = React.memo<{
disableLinks?: boolean;
raw?: string;
onClickTimeline?: (timelineId: string) => void;
onClickTimeline?: (timelineId: string, graphEventId?: string) => void;
size?: 'xs' | 's' | 'm';
}>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => {
const markdownRenderers = {
@ -63,11 +64,14 @@ export const Markdown = React.memo<{
),
link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => {
if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) {
const timelineId = href.split('timelines?timeline=(id:')[1].split("'")[1] ?? '';
const timelineId = clone(href).split('timeline=(id:')[1].split("'")[1] ?? '';
const graphEventId = href.includes('graphEventId:')
? clone(href).split('graphEventId:')[1].split("'")[1] ?? ''
: '';
return (
<EuiToolTip content={i18n.TIMELINE_ID(timelineId)}>
<EuiLink
onClick={() => onClickTimeline(timelineId)}
onClick={() => onClickTimeline(timelineId, graphEventId)}
data-test-subj="markdown-timeline-link"
>
{children}

View file

@ -16,7 +16,7 @@ interface IMarkdownEditorForm {
field: FieldHook;
idAria: string;
isDisabled: boolean;
onClickTimeline?: (timelineId: string) => void;
onClickTimeline?: (timelineId: string, graphEventId?: string) => void;
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
topRightContent?: React.ReactNode;

View file

@ -74,7 +74,7 @@ export const MarkdownEditor = React.memo<{
content: string;
isDisabled?: boolean;
onChange: (description: string) => void;
onClickTimeline?: (timelineId: string) => void;
onClickTimeline?: (timelineId: string, graphEventId?: string) => void;
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
}>(
@ -95,15 +95,18 @@ export const MarkdownEditor = React.memo<{
[onChange]
);
const setCursorPosition = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onCursorPositionUpdate) {
onCursorPositionUpdate({
start: e!.target!.selectionStart ?? 0,
end: e!.target!.selectionEnd ?? 0,
});
}
return false;
};
const setCursorPosition = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onCursorPositionUpdate) {
onCursorPositionUpdate({
start: e!.target!.selectionStart ?? 0,
end: e!.target!.selectionEnd ?? 0,
});
}
return false;
},
[onCursorPositionUpdate]
);
const tabs = useMemo(
() => [
@ -135,8 +138,7 @@ export const MarkdownEditor = React.memo<{
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[content, isDisabled, placeholder]
[content, handleOnChange, isDisabled, onClickTimeline, placeholder, setCursorPosition]
);
return (
<Container>

View file

@ -18,6 +18,7 @@ import {
getTitle,
replaceStateInLocation,
updateUrlStateString,
decodeRisonUrlState,
} from './helpers';
import {
UrlStateContainerPropTypes,
@ -26,8 +27,10 @@ import {
KeyUrlState,
ALL_URL_STATE_KEYS,
UrlStateToRedux,
UrlState,
} from './types';
import { SecurityPageName } from '../../../app/types';
import { TimelineUrl } from '../../../timelines/store/timeline/model';
function usePrevious(value: PreviousLocationUrlState) {
const ref = useRef<PreviousLocationUrlState>(value);
@ -37,6 +40,21 @@ function usePrevious(value: PreviousLocationUrlState) {
return ref.current;
}
const updateTimelineAtinitialization = (
urlKey: CONSTANTS,
newUrlStateString: string,
urlState: UrlState
) => {
let updateUrlState = true;
if (urlKey === CONSTANTS.timeline) {
const timeline = decodeRisonUrlState<TimelineUrl>(newUrlStateString);
if (timeline != null && urlState.timeline.id === timeline.id) {
updateUrlState = false;
}
}
return updateUrlState;
};
export const useUrlStateHooks = ({
detailName,
indexPattern,
@ -78,13 +96,15 @@ export const useUrlStateHooks = ({
getParamFromQueryString(getQueryStringFromLocation(mySearch), urlKey) ??
newUrlStateString;
if (isInitializing || !deepEqual(updatedUrlStateString, newUrlStateString)) {
urlStateToUpdate = [
...urlStateToUpdate,
{
urlKey,
newUrlStateString: updatedUrlStateString,
},
];
if (updateTimelineAtinitialization(urlKey, newUrlStateString, urlState)) {
urlStateToUpdate = [
...urlStateToUpdate,
{
urlKey,
newUrlStateString: updatedUrlStateString,
},
];
}
}
}
} else if (

View file

@ -17,6 +17,10 @@ const WithHoverActionsPopover = (styled(EuiPopover as any)`
}
` as unknown) as typeof EuiPopover;
const Container = styled.div`
width: fit-content;
`;
interface Props {
/**
* Always show the hover menu contents (default: false)
@ -75,7 +79,7 @@ export const WithHoverActions = React.memo<Props>(
}, [closePopOverTrigger]);
return (
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Container onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<WithHoverActionsPopover
anchorPosition={'downCenter'}
button={content}
@ -86,7 +90,7 @@ export const WithHoverActions = React.memo<Props>(
>
{isOpen ? <>{hoverContent}</> : null}
</WithHoverActionsPopover>
</div>
</Container>
);
}
);

View file

@ -374,7 +374,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
}
}, [defaultFilters, filterGroup]);
const { filterManager } = useKibana().services.data.query;
const { initializeTimeline, setTimelineRowActions } = useManageTimeline();
const { initializeTimeline, setTimelineRowActions, setIndexToAdd } = useManageTimeline();
useEffect(() => {
initializeTimeline({
@ -383,6 +383,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
filterManager,
footerText: i18n.TOTAL_COUNT_OF_ALERTS,
id: timelineId,
indexToAdd: defaultIndices,
loadingText: i18n.LOADING_ALERTS,
selectAll: canUserCRUD ? selectAll : false,
timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })],
@ -390,6 +391,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setTimelineRowActions({
id: timelineId,
@ -398,6 +400,11 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [additionalActions]);
useEffect(() => {
setIndexToAdd({ id: timelineId, indexToAdd: defaultIndices });
}, [timelineId, defaultIndices, setIndexToAdd]);
const headerFilterGroup = useMemo(
() => <AlertsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />,
[onFilterGroupChangedCallback]

View file

@ -31,6 +31,7 @@ const EuiFlyoutContainer = styled.div`
z-index: 4001;
min-width: 150px;
width: auto;
animation: none;
}
`;

View file

@ -12,7 +12,7 @@ import styled from 'styled-components';
import { SecurityPageName } from '../../../app/types';
import { AllCasesModal } from '../../../cases/components/all_cases_modal';
import { getCaseDetailsUrl } from '../../../common/components/link_to';
import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to';
import { APP_ID } from '../../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { State } from '../../../common/store';
@ -28,6 +28,7 @@ import {
import { Resolver } from '../../../resolver/view';
import * as i18n from './translations';
import { TimelineType } from '../../../../common/types/timeline';
const OverlayContainer = styled.div<{ bodyHeight?: number }>`
height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')};
@ -44,6 +45,7 @@ interface OwnProps {
bodyHeight?: number;
graphEventId?: string;
timelineId: string;
timelineType: TimelineType;
}
const GraphOverlayComponent = ({
@ -52,6 +54,7 @@ const GraphOverlayComponent = ({
status,
timelineId,
title,
timelineType,
}: OwnProps & PropsFromRedux) => {
const dispatch = useDispatch();
const { navigateToApp } = useKibana().services.application;
@ -65,20 +68,20 @@ const GraphOverlayComponent = ({
timelineSelectors.selectTimeline(state, timelineId)
);
const onRowClick = useCallback(
(id: string) => {
(id?: string) => {
onCloseCaseModal();
dispatch(
setInsertTimeline({
graphEventId,
timelineId,
timelineSavedObjectId: currentTimeline.savedObjectId,
timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE,
})
);
navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: getCaseDetailsUrl({ id }),
path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(),
}).then(() => {
dispatch(
setInsertTimeline({
graphEventId,
timelineId,
timelineSavedObjectId: currentTimeline.savedObjectId,
timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE,
})
);
});
},
[currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title]
@ -93,28 +96,30 @@ const GraphOverlayComponent = ({
{i18n.BACK_TO_EVENTS}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<NewCase
compact={true}
graphEventId={graphEventId}
onClosePopover={noop}
timelineId={timelineId}
timelineTitle={title}
timelineStatus={status}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ExistingCase
compact={true}
onClosePopover={noop}
onOpenCaseModal={onOpenCaseModal}
timelineStatus={status}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{timelineType === TimelineType.default && (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<NewCase
compact={true}
graphEventId={graphEventId}
onClosePopover={noop}
timelineId={timelineId}
timelineTitle={title}
timelineStatus={status}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ExistingCase
compact={true}
onClosePopover={noop}
onOpenCaseModal={onOpenCaseModal}
timelineStatus={status}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />

View file

@ -138,6 +138,7 @@ const reducerManageTimeline = (
};
interface UseTimelineManager {
getIndexToAddById: (id: string) => string[] | null;
getManageTimelineById: (id: string) => ManageTimeline;
getTimelineFilterManager: (id: string) => FilterManager | undefined;
initializeTimeline: (newTimeline: ManageTimelineInit) => void;
@ -216,9 +217,19 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT
},
[initializeTimeline, state]
);
const getIndexToAddById = useCallback(
(id: string): string[] | null => {
if (state[id] != null) {
return state[id].indexToAdd;
}
return getTimelineDefaults(id).indexToAdd;
},
[state]
);
const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]);
return {
getIndexToAddById,
getManageTimelineById,
getTimelineFilterManager,
initializeTimeline,
@ -231,6 +242,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT
const init = {
getManageTimelineById: (id: string) => getTimelineDefaults(id),
getIndexToAddById: (id: string) => null,
getTimelineFilterManager: () => undefined,
setIndexToAdd: () => undefined,
isManagedTimeline: () => false,

View file

@ -90,14 +90,17 @@ export const useTimelineTypes = ({
);
const onFilterClicked = useCallback(
(tabId) => {
if (tabId === timelineType) {
setTimelineTypes(null);
} else {
setTimelineTypes(tabId);
}
(tabId, tabStyle: TimelineTabsStyle) => {
setTimelineTypes((prevTimelineTypes) => {
if (tabId === prevTimelineTypes && tabStyle === TimelineTabsStyle.filter) {
return null;
} else if (prevTimelineTypes !== tabId) {
setTimelineTypes(tabId);
}
return prevTimelineTypes;
});
},
[timelineType, setTimelineTypes]
[setTimelineTypes]
);
const timelineTabs = useMemo(() => {
@ -112,7 +115,7 @@ export const useTimelineTypes = ({
href={tab.href}
onClick={(ev) => {
tab.onClick(ev);
onFilterClicked(tab.id);
onFilterClicked(tab.id, TimelineTabsStyle.tab);
}}
>
{tab.name}
@ -133,7 +136,7 @@ export const useTimelineTypes = ({
numFilters={tab.count}
onClick={(ev: { preventDefault: () => void }) => {
tab.onClick(ev);
onFilterClicked(tab.id);
onFilterClicked(tab.id, TimelineTabsStyle.filter);
}}
withNext={tab.withNext}
>

View file

@ -9,7 +9,6 @@ import React from 'react';
import { BrowserFields, DocValueFields } from '../../../../../common/containers/source';
import { TimelineItem, TimelineNonEcsData } from '../../../../../graphql/types';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { maxDelay } from '../../../../../common/lib/helpers/scheduler';
import { Note } from '../../../../../common/lib/note';
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
import {
@ -81,12 +80,13 @@ const EventsComponent: React.FC<Props> = ({
<EventsTbody data-test-subj="events">
{data.map((event, i) => (
<StatefulEvent
containerElementRef={containerElementRef}
actionsColumnWidth={actionsColumnWidth}
addNoteToEvent={addNoteToEvent}
browserFields={browserFields}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
containerElementRef={containerElementRef}
disableSensorVisibility={data != null && data.length < 101}
docValueFields={docValueFields}
event={event}
eventIdToNoteIds={eventIdToNoteIds}
@ -95,7 +95,6 @@ const EventsComponent: React.FC<Props> = ({
isEventViewer={isEventViewer}
key={`${event._id}_${event._index}`}
loadingEventIds={loadingEventIds}
maxDelay={maxDelay(i)}
onColumnResized={onColumnResized}
onPinEvent={onPinEvent}
onRowSelected={onRowSelected}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useRef, useState, useCallback } from 'react';
import { useSelector } from 'react-redux';
import uuid from 'uuid';
import VisibilitySensor from 'react-visibility-sensor';
@ -12,7 +12,6 @@ import VisibilitySensor from 'react-visibility-sensor';
import { BrowserFields, DocValueFields } from '../../../../../common/containers/source';
import { TimelineDetailsQuery } from '../../../../containers/details';
import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types';
import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler';
import { Note } from '../../../../../common/lib/note';
import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model';
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
@ -43,13 +42,13 @@ interface Props {
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
columnRenderers: ColumnRenderer[];
disableSensorVisibility: boolean;
docValueFields: DocValueFields[];
event: TimelineItem;
eventIdToNoteIds: Readonly<Record<string, string[]>>;
getNotesByIds: (noteIds: string[]) => Note[];
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
maxDelay?: number;
onColumnResized: OnColumnResized;
onPinEvent: OnPinEvent;
onRowSelected: OnRowSelected;
@ -109,6 +108,7 @@ const StatefulEventComponent: React.FC<Props> = ({
containerElementRef,
columnHeaders,
columnRenderers,
disableSensorVisibility = true,
docValueFields,
event,
eventIdToNoteIds,
@ -116,7 +116,6 @@ const StatefulEventComponent: React.FC<Props> = ({
isEventViewer = false,
isEventPinned = false,
loadingEventIds,
maxDelay = 0,
onColumnResized,
onPinEvent,
onRowSelected,
@ -130,7 +129,6 @@ const StatefulEventComponent: React.FC<Props> = ({
updateNote,
}) => {
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({});
const [initialRender, setInitialRender] = useState(false);
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const timeline = useSelector<StoreState, TimelineModel>((state) => {
return state.timeline.timelineById['timeline-1'];
@ -160,39 +158,9 @@ const StatefulEventComponent: React.FC<Props> = ({
[addNoteToEvent, event, isEventPinned, onPinEvent]
);
/**
* Incrementally loads the events when it mounts by trying to
* see if it resides within a window frame and if it is it will
* indicate to React that it should render its self by setting
* its initialRender to true.
*/
useEffect(() => {
let _isMounted = true;
requestIdleCallbackViaScheduler(
() => {
if (!initialRender && _isMounted) {
setInitialRender(true);
}
},
{ timeout: maxDelay }
);
return () => {
_isMounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Number of current columns plus one for actions.
const columnCount = columnHeaders.length + 1;
// If we are not ready to render yet, just return null
// see useEffect() for when it schedules the first
// time this stateful component should be rendered.
if (!initialRender) {
return <SkeletonRow cellCount={columnCount} />;
}
return (
<VisibilitySensor
partialVisibility={true}
@ -201,7 +169,7 @@ const StatefulEventComponent: React.FC<Props> = ({
offset={{ top: TOP_OFFSET, bottom: BOTTOM_OFFSET }}
>
{({ isVisible }) => {
if (isVisible) {
if (isVisible || disableSensorVisibility) {
return (
<TimelineDetailsQuery
docValueFields={docValueFields}
@ -293,8 +261,8 @@ const StatefulEventComponent: React.FC<Props> = ({
} else {
// Height place holder for visibility detection as well as re-rendering sections.
const height =
divElement.current != null && divElement.current.clientHeight
? `${divElement.current.clientHeight}px`
divElement.current != null && divElement.current!.clientHeight
? `${divElement.current!.clientHeight}px`
: DEFAULT_ROW_HEIGHT;
return <SkeletonRow cellCount={columnCount} rowHeight={height} />;

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme';
import React from 'react';
import { useSelector } from 'react-redux';
@ -18,7 +18,7 @@ import { Sort } from './sort';
import { wait } from '../../../../common/lib/helpers';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { SELECTOR_TIMELINE_BODY_CLASS_NAME, TimelineBody } from '../styles';
import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme';
import { TimelineType } from '../../../../../common/types/timeline';
const testBodyHeight = 700;
const mockGetNotesByIds = (eventId: string[]) => [];
@ -83,6 +83,7 @@ describe('Body', () => {
show: true,
sort: mockSort,
showCheckboxes: false,
timelineType: TimelineType.default,
toggleColumn: jest.fn(),
updateNote: jest.fn(),
};

View file

@ -33,6 +33,7 @@ import { useManageTimeline } from '../../manage_timeline';
import { GraphOverlay } from '../../graph_overlay';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers';
import { TimelineRowAction } from './actions';
import { TimelineType } from '../../../../../common/types/timeline';
export interface BodyProps {
addNoteToEvent: AddNoteToEvent;
@ -64,6 +65,7 @@ export interface BodyProps {
show: boolean;
showCheckboxes: boolean;
sort: Sort;
timelineType: TimelineType;
toggleColumn: (column: ColumnHeaderOptions) => void;
updateNote: UpdateNote;
}
@ -101,6 +103,7 @@ export const Body = React.memo<BodyProps>(
showCheckboxes,
sort,
toggleColumn,
timelineType,
updateNote,
}) => {
const containerElementRef = useRef<HTMLDivElement>(null);
@ -148,7 +151,12 @@ export const Body = React.memo<BodyProps>(
return (
<>
{graphEventId && (
<GraphOverlay bodyHeight={height} graphEventId={graphEventId} timelineId={id} />
<GraphOverlay
bodyHeight={height}
graphEventId={graphEventId}
timelineId={id}
timelineType={timelineType}
/>
)}
<TimelineBody
data-test-subj="timeline-body"

View file

@ -79,6 +79,7 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
showCheckboxes,
graphEventId,
sort,
timelineType,
toggleColumn,
unPinEvent,
updateColumns,
@ -218,6 +219,7 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
show={id === TimelineId.active ? show : true}
showCheckboxes={showCheckboxes}
sort={sort}
timelineType={timelineType}
toggleColumn={toggleColumn}
updateNote={onUpdateNote}
/>
@ -241,7 +243,8 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
prevProps.show === nextProps.show &&
prevProps.selectedEventIds === nextProps.selectedEventIds &&
prevProps.showCheckboxes === nextProps.showCheckboxes &&
prevProps.sort === nextProps.sort
prevProps.sort === nextProps.sort &&
prevProps.timelineType === nextProps.timelineType
);
StatefulBodyComponent.displayName = 'StatefulBodyComponent';
@ -268,6 +271,7 @@ const makeMapStateToProps = () => {
selectedEventIds,
show,
showCheckboxes,
timelineType,
} = timeline;
return {
@ -284,6 +288,7 @@ const makeMapStateToProps = () => {
selectedEventIds,
show,
showCheckboxes,
timelineType,
};
};
return mapStateToProps;

View file

@ -215,6 +215,7 @@ const StatefulTimelineComponent = React.memo<Props>(
/>
);
},
// eslint-disable-next-line complexity
(prevProps, nextProps) => {
return (
prevProps.eventType === nextProps.eventType &&
@ -223,6 +224,7 @@ const StatefulTimelineComponent = React.memo<Props>(
prevProps.id === nextProps.id &&
prevProps.isLive === nextProps.isLive &&
prevProps.isSaving === nextProps.isSaving &&
prevProps.isTimelineExists === nextProps.isTimelineExists &&
prevProps.itemsPerPage === nextProps.itemsPerPage &&
prevProps.kqlMode === nextProps.kqlMode &&
prevProps.kqlQueryExpression === nextProps.kqlQueryExpression &&

View file

@ -25,7 +25,7 @@ import { timelineSelectors } from '../../../store/timeline';
import { setInsertTimeline } from '../../../store/timeline/actions';
import { useKibana } from '../../../../common/lib/kibana';
import { APP_ID } from '../../../../../common/constants';
import { getCaseDetailsUrl } from '../../../../common/components/link_to';
import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../../common/components/link_to';
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
type UpdateTitle = ({ id, title }: { id: string; title: string }) => void;
@ -111,11 +111,11 @@ export const Properties = React.memo<Props>(
);
const onRowClick = useCallback(
(id: string) => {
(id?: string) => {
onCloseCaseModal();
navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: getCaseDetailsUrl({ id }),
path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(),
}).then(() =>
dispatch(
setInsertTimeline({