Skip to content

Commit

Permalink
[Security Solution][Endpoint] Refresh action and comments on the Case…
Browse files Browse the repository at this point in the history
… Details view when Isolation actions are created (#103160)

* Expose new Prop from `CaseComponent` that exposes data refresh callbacks
* Refresh case actions and comments if isolation was created successfully
  • Loading branch information
paul-tavares authored Jun 28, 2021
1 parent 6488414 commit 91fc3cc
Show file tree
Hide file tree
Showing 13 changed files with 234 additions and 52 deletions.
17 changes: 17 additions & 0 deletions x-pack/plugins/cases/common/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ export type StatusAllType = typeof StatusAll;

export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType;

/**
* The type for the `refreshRef` prop (a `React.Ref`) defined by the `CaseViewComponentProps`.
*
* @example
* const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
* return <CaseComponent refreshRef={refreshRef} ...otherProps>
*/
export type CaseViewRefreshPropInterface = null | {
/**
* Refreshes the all of the user actions/comments in the view's timeline
* (note: this also triggers a silent `refreshCase()`)
*/
refreshUserActionsAndComments: () => Promise<void>;
/** Refreshes the Case information only */
refreshCase: () => Promise<void>;
};

export type Comment = CommentRequest & {
associationType: AssociationType;
id: string;
Expand Down
72 changes: 68 additions & 4 deletions x-pack/plugins/cases/public/components/case_view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import {
Expand All @@ -16,11 +16,19 @@ import {
EuiHorizontalRule,
} from '@elastic/eui';

import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector, Ecs } from '../../../common';
import {
CaseStatuses,
CaseAttributes,
CaseType,
Case,
CaseConnector,
Ecs,
CaseViewRefreshPropInterface,
} from '../../../common';
import { HeaderPage } from '../header_page';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { useGetCase } from '../../containers/use_get_case';
import { UseGetCase, useGetCase } from '../../containers/use_get_case';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
Expand All @@ -42,6 +50,7 @@ import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';

const gutterTimeline = '70px'; // seems to be a timeline reference from the original file

export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation;
Expand All @@ -54,12 +63,18 @@ export interface CaseViewComponentProps {
subCaseId?: string;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
*/
refreshRef?: MutableRefObject<CaseViewRefreshPropInterface>;
}

export interface CaseViewProps extends CaseViewComponentProps {
onCaseDataSuccess?: (data: Case) => void;
timelineIntegration?: CasesTimelineIntegration;
}

export interface OnUpdateFields {
key: keyof Case;
value: Case[keyof Case];
Expand All @@ -78,13 +93,14 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)`

const MyEuiHorizontalRule = styled(EuiHorizontalRule)`
margin-left: 48px;
&.euiHorizontalRule--full {
width: calc(100% - 48px);
}
`;

export interface CaseComponentProps extends CaseViewComponentProps {
fetchCase: () => void;
fetchCase: UseGetCase['fetchCase'];
caseData: Case;
updateCase: (newCase: Case) => void;
}
Expand All @@ -105,6 +121,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
updateCase,
useFetchAlertData,
userCanCrud,
refreshRef,
}) => {
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
Expand All @@ -124,6 +141,51 @@ export const CaseComponent = React.memo<CaseComponentProps>(
subCaseId,
});

// Set `refreshRef` if needed
useEffect(() => {
let isStale = false;

if (refreshRef) {
refreshRef.current = {
refreshCase: async () => {
// Do nothing if component (or instance of this render cycle) is stale
if (isStale) {
return;
}

await fetchCase();
},
refreshUserActionsAndComments: async () => {
// Do nothing if component (or instance of this render cycle) is stale
// -- OR --
// it is already loading
if (isStale || isLoadingUserActions) {
return;
}

await Promise.all([
fetchCase(true),
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId),
]);
},
};

return () => {
isStale = true;
refreshRef.current = null;
};
}
}, [
caseData.connector.id,
caseId,
fetchCase,
fetchCaseUserActions,
isLoadingUserActions,
refreshRef,
subCaseId,
updateCase,
]);

// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
Expand Down Expand Up @@ -491,6 +553,7 @@ export const CaseView = React.memo(
timelineIntegration,
useFetchAlertData,
userCanCrud,
refreshRef,
}: CaseViewProps) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
if (isError) {
Expand Down Expand Up @@ -528,6 +591,7 @@ export const CaseView = React.memo(
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
refreshRef={refreshRef}
/>
</OwnerProvider>
</CasesTimelineIntegrationProvider>
Expand Down
13 changes: 13 additions & 0 deletions x-pack/plugins/cases/public/containers/use_get_case.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ describe('useGetCase', () => {
});
});

it('set isLoading to false when refetching case "silent"ly', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() =>
useGetCase(basicCase.id)
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCase(true);

expect(result.current.isLoading).toBe(false);
});
});

it('unhappy path', async () => {
const spyOnGetCase = jest.spyOn(api, 'getCase');
spyOnGetCase.mockImplementation(() => {
Expand Down
61 changes: 35 additions & 26 deletions x-pack/plugins/cases/public/containers/use_get_case.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface CaseState {
}

type Action =
| { type: 'FETCH_INIT' }
| { type: 'FETCH_INIT'; payload: { silent: boolean } }
| { type: 'FETCH_SUCCESS'; payload: Case }
| { type: 'FETCH_FAILURE' }
| { type: 'UPDATE_CASE'; payload: Case };
Expand All @@ -29,7 +29,10 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
// If doing a silent fetch, then don't set `isLoading`. This helps
// with preventing screen flashing when wanting to refresh the actions
// and comments
isLoading: !action.payload?.silent,
isError: false,
};
case 'FETCH_SUCCESS':
Expand All @@ -56,7 +59,11 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
};

export interface UseGetCase extends CaseState {
fetchCase: () => void;
/**
* @param [silent] When set to `true`, the `isLoading` property will not be set to `true`
* while doing the API call
*/
fetchCase: (silent?: boolean) => Promise<void>;
updateCase: (newCase: Case) => void;
}

Expand All @@ -74,33 +81,35 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
dispatch({ type: 'UPDATE_CASE', payload: newCase });
}, []);

const callFetch = useCallback(async () => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT' });
const callFetch = useCallback(
async (silent: boolean = false) => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: { silent } });

const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
: getCase(caseId, true, abortCtrlRef.current.signal));
const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
: getCase(caseId, true, abortCtrlRef.current.signal));

if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
}
dispatch({ type: 'FETCH_FAILURE' });
}
dispatch({ type: 'FETCH_FAILURE' });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId, subCaseId]);
},
[caseId, subCaseId, toasts]
);

useEffect(() => {
callFetch();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export const initialData: CaseUserActionsState = {
};

export interface UseGetCaseUserActions extends CaseUserActionsState {
fetchCaseUserActions: (caseId: string, caseConnectorId: string, subCaseId?: string) => void;
fetchCaseUserActions: (
caseId: string,
caseConnectorId: string,
subCaseId?: string
) => Promise<void>;
}

const getExternalService = (value: string): CaseExternalService | null =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React, { useCallback, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { SearchResponse } from 'elasticsearch';
import { isEmpty } from 'lodash';
Expand All @@ -19,7 +19,7 @@ import {
useFormatUrl,
} from '../../../common/components/link_to';
import { Ecs } from '../../../../common/ecs';
import { Case } from '../../../../../cases/common';
import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common';
import { TimelineId } from '../../../../common/types/timeline';
import { SecurityPageName } from '../../../app/types';
import { KibanaServices, useKibana } from '../../../common/lib/kibana';
Expand All @@ -38,6 +38,7 @@ import { SEND_ALERT_TO_TIMELINE } from './translations';
import { useInsertTimeline } from '../use_insert_timeline';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline';
import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';

interface Props {
caseId: string;
Expand Down Expand Up @@ -176,9 +177,13 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
})
);
}, [dispatch]);

const refreshRef = useRef<CaseViewRefreshPropInterface>(null);

return (
<>
<CaseDetailsRefreshContext.Provider value={refreshRef}>
{casesUi.getCaseView({
refreshRef,
allCasesNavigation: {
href: formattedAllCasesLink,
onClick: async (e) => {
Expand Down Expand Up @@ -247,7 +252,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
userCanCrud,
})}
<SpyRoute state={spyState} pageName={SecurityPageName.case} />
</>
</CaseDetailsRefreshContext.Provider>
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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, { MutableRefObject, useContext } from 'react';
import { CaseViewRefreshPropInterface } from '../../../../../../cases/common';

/**
* React Context that can hold the `Ref` that is created an passed to `CaseViewProps['refreshRef`]`, enabling
* child components to trigger a refresh of a case.
*/
export const CaseDetailsRefreshContext = React.createContext<MutableRefObject<CaseViewRefreshPropInterface> | null>(
null
);

/**
* Returns the closes CaseDetails Refresh interface if any. Used in conjuction with `CaseDetailsRefreshContext` component
*
* @example
* // Higher-order component
* const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
* return <CaseDetailsRefreshContext.Provider value={refreshRef}>....</CaseDetailsRefreshContext.Provider>
*
* // Now, use the hook from a hild component that was rendered inside of `<CaseDetailsRefreshContext.Provider>`
* const caseDetailsRefresh = useWithCaseDetailsRefresh();
* ...
* if (caseDetailsRefresh) {
* caseDetailsRefresh.refreshUserActionsAndComments();
* }
*/
export const useWithCaseDetailsRefresh = (): Readonly<CaseViewRefreshPropInterface> | undefined => {
return useContext(CaseDetailsRefreshContext)?.current;
};
Loading

0 comments on commit 91fc3cc

Please sign in to comment.