[Cases] Integrate routes and navigation (#117582)

* getCases function and router

* all pages router

* navigation hooks created

* external navigations removed

* basePath in cases context

* context optimization

* no privileges screen

* new files

* CasesDeepLinkIds constant renamed

* remove props spreading

* AllCasesList tests

* Fix types and tests: Part 1

* Fix types and tests: Part 2

* Move glasses badge logic inside cases

* Fix export types

* Improve helpers

* observability changes integrated

* Small fixes

* Fix timelines unit tests

* Add readonly badge test

* test fixed

* form context test fixed

* fix breadcrumbs test

* fix types in o11y routes

* Fix more tests

* Fix bug

* urlType fixes

* Fix cypress tests

* configure header conflict solved

* Fix i18n

* fix breadcrumbs test

* tests and suggestions

* Add navigation tests

* README updated

* update plugin list docs

* Add more tests

* Fix i18n

* More tests

* Fix README

* Fix types

* fix resolve redirect paths

* fix flyout z-index on timeline

* add flyout z-index class comment

* use kibana currentAppId and application observables instead of passing props

* Get application info from the hook

* Fix tests

* Fix more tests

* tests fixed

* Fix container tests

* Fix container tests

* test updated

Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
This commit is contained in:
Sergi Massaneda 2021-11-19 19:42:35 +01:00 committed by GitHub
parent d6217470e6
commit 4eb797a8b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
210 changed files with 4031 additions and 6574 deletions

View file

@ -7,7 +7,11 @@
import React from 'react';
import { mount } from 'enzyme';
import { TestProviders, mockGetAllCasesSelectorModal } from '../../../../mock';
import {
TestProviders,
mockGetAllCasesSelectorModal,
mockGetCreateCaseFlyout,
} from '../../../../mock';
import { AddToCaseAction } from './add_to_case_action';
import { SECURITY_SOLUTION_OWNER } from '../../../../../../cases/common';
import { AddToCaseActionButton } from './add_to_case_action_button';
@ -68,7 +72,7 @@ describe('AddToCaseAction', () => {
expect(wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).exists()).toBeTruthy();
});
it('it opens the create case modal', () => {
it('it opens the create case flyout', () => {
const wrapper = mount(
<TestProviders>
<AddToCaseActionButton {...props} />
@ -78,7 +82,7 @@ describe('AddToCaseAction', () => {
wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click');
wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click');
expect(wrapper.find('[data-test-subj="create-case-flyout"]').exists()).toBeTruthy();
expect(mockGetCreateCaseFlyout).toHaveBeenCalled();
});
it('it opens the all cases modal', () => {
@ -92,7 +96,7 @@ describe('AddToCaseAction', () => {
wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click');
wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click');
expect(wrapper.find('[data-test-subj="all-cases-modal"]')).toBeTruthy();
expect(mockGetAllCasesSelectorModal).toHaveBeenCalled();
});
it('it set rule information as null when missing', () => {

View file

@ -12,12 +12,9 @@ import { TimelineItem } from '../../../../../common/';
import { useAddToCase, normalizedEventFields } from '../../../../hooks/use_add_to_case';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { TimelinesStartServices } from '../../../../types';
import { CreateCaseFlyout } from './create/flyout';
import { tGridActions } from '../../../../';
import * as i18n from './translations';
export interface AddToCaseActionProps {
ariaLabel?: string;
event?: TimelineItem;
useInsertTimeline?: Function;
casePermissions: {
@ -31,7 +28,6 @@ export interface AddToCaseActionProps {
}
const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL,
event,
useInsertTimeline,
casePermissions,
@ -46,15 +42,13 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
const { cases } = useKibana<TimelinesStartServices>().services;
const {
onCaseClicked,
goToCreateCase,
onCaseSuccess,
attachAlertToCase,
createCaseUrl,
isAllCaseModalOpen,
isCreateCaseFlyoutOpen,
} = useAddToCase({ event, useInsertTimeline, casePermissions, appId, owner, onClose });
} = useAddToCase({ event, casePermissions, appId, owner, onClose });
const getAllCasesSelectorModalProps = useMemo(() => {
const allCasesSelectorModalProps = useMemo(() => {
const { ruleId, ruleName } = normalizedEventFields(event);
return {
alertData: {
@ -66,10 +60,6 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
},
owner,
},
createCaseNavigation: {
href: createCaseUrl,
onClick: goToCreateCase,
},
hooks: {
useInsertTimeline,
},
@ -85,8 +75,6 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
casePermissions?.crud,
onCaseSuccess,
onCaseClicked,
createCaseUrl,
goToCreateCase,
eventId,
eventIndex,
dispatch,
@ -99,19 +87,30 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
dispatch(tGridActions.setOpenAddToNewCase({ id: eventId, isOpen: false }));
}, [dispatch, eventId]);
const createCaseFlyoutProps = useMemo(() => {
return {
afterCaseCreated: attachAlertToCase,
onClose: closeCaseFlyoutOpen,
onSuccess: onCaseSuccess,
useInsertTimeline,
owner: [owner],
disableAlerts,
userCanCrud: casePermissions?.crud ?? false,
};
}, [
attachAlertToCase,
closeCaseFlyoutOpen,
onCaseSuccess,
useInsertTimeline,
owner,
disableAlerts,
casePermissions,
]);
return (
<>
{isCreateCaseFlyoutOpen && (
<CreateCaseFlyout
afterCaseCreated={attachAlertToCase}
onCloseFlyout={closeCaseFlyoutOpen}
onSuccess={onCaseSuccess}
useInsertTimeline={useInsertTimeline}
owner={owner}
disableAlerts={disableAlerts}
/>
)}
{isAllCaseModalOpen && cases.getAllCasesSelectorModal(getAllCasesSelectorModalProps)}
{isCreateCaseFlyoutOpen && cases.getCreateCaseFlyout(createCaseFlyoutProps)}
{isAllCaseModalOpen && cases.getAllCasesSelectorModal(allCasesSelectorModalProps)}
</>
);
};

View file

@ -20,7 +20,6 @@ import { ActionIconItem } from '../../action_icon_item';
import * as i18n from './translations';
const AddToCaseActionButtonComponent: React.FC<AddToCaseActionProps> = ({
ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL,
event,
useInsertTimeline,
casePermissions,
@ -71,16 +70,16 @@ const AddToCaseActionButtonComponent: React.FC<AddToCaseActionProps> = ({
() => (
<EuiToolTip data-test-subj="attach-alert-to-case-tooltip" content={tooltipContext}>
<EuiButtonIcon
aria-label={ariaLabel}
data-test-subj="attach-alert-to-case-button"
size="s"
iconType="folderClosed"
onClick={openPopover}
isDisabled={isDisabled}
aria-label={tooltipContext}
/>
</EuiToolTip>
),
[ariaLabel, isDisabled, openPopover, tooltipContext]
[isDisabled, openPopover, tooltipContext]
);
return (

View file

@ -12,7 +12,11 @@ import { useAddToCase } from '../../../../hooks/use_add_to_case';
import { AddToCaseActionProps } from './add_to_case_action';
import * as i18n from './translations';
const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
interface AddToCaseActionButtonProps extends AddToCaseActionProps {
ariaLabel?: string;
}
const AddToCaseActionButtonComponent: React.FC<AddToCaseActionButtonProps> = ({
ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL,
event,
useInsertTimeline,
@ -47,7 +51,7 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
);
};
export const AddToExistingCaseButton = memo(AddToCaseActionComponent);
export const AddToExistingCaseButton = memo(AddToCaseActionButtonComponent);
// eslint-disable-next-line import/no-default-export
export default AddToExistingCaseButton;

View file

@ -12,7 +12,11 @@ import { useAddToCase } from '../../../../hooks/use_add_to_case';
import { AddToCaseActionProps } from './add_to_case_action';
import * as i18n from './translations';
const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
export interface AddToNewCaseButtonProps extends AddToCaseActionProps {
ariaLabel?: string;
}
const AddToNewCaseButtonComponent: React.FC<AddToNewCaseButtonProps> = ({
ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL,
event,
useInsertTimeline,
@ -48,7 +52,7 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
);
};
export const AddToNewCaseButton = memo(AddToCaseActionComponent);
export const AddToNewCaseButton = memo(AddToNewCaseButtonComponent);
// eslint-disable-next-line import/no-default-export
export default AddToNewCaseButton;

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { CreateCaseFlyout } from './flyout';
import { TestProviders } from '../../../../../mock';
const onCloseFlyout = jest.fn();
const onSuccess = jest.fn();
const defaultProps = {
onCloseFlyout,
onSuccess,
owner: 'securitySolution',
};
describe('CreateCaseFlyout', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('renders', () => {
const wrapper = mount(
<TestProviders>
<CreateCaseFlyout {...defaultProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy();
});
it('Closing modal calls onCloseCaseModal', () => {
const wrapper = mount(
<TestProviders>
<CreateCaseFlyout {...defaultProps} />
</TestProviders>
);
wrapper.find(`[data-test-subj='euiFlyoutCloseButton']`).first().simulate('click');
expect(onCloseFlyout).toBeCalled();
});
});

View file

@ -1,110 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
import * as i18n from '../translations';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
import { Case } from '../../../../../../../cases/common';
import type { TimelinesStartServices } from '../../../../../types';
export interface CreateCaseModalProps {
afterCaseCreated?: (theCase: Case) => Promise<void>;
onCloseFlyout: () => void;
onSuccess: (theCase: Case) => Promise<void>;
useInsertTimeline?: Function;
owner: string;
disableAlerts?: boolean;
}
const StyledFlyout = styled(EuiFlyout)`
${({ theme }) => `
z-index: ${theme.eui.euiZLevel5};
`}
`;
const maskOverlayClassName = 'create-case-flyout-mask-overlay';
/**
* We need to target the mask overlay which is a parent element
* of the flyout.
* A global style is needed to target a parent element.
*/
const GlobalStyle = createGlobalStyle<{ theme: { eui: { euiZLevel5: number } } }>`
.${maskOverlayClassName} {
${({ theme }) => `
z-index: ${theme.eui.euiZLevel5};
`}
}
`;
// Adding bottom padding because timeline's
// bottom bar gonna hide the submit button.
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
${({ theme }) => `
&& .euiFlyoutBody__overflow {
overflow-y: auto;
overflow-x: hidden;
}
&& .euiFlyoutBody__overflowContent {
display: block;
padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 70px;
height: auto;
}
`}
`;
const FormWrapper = styled.div`
width: 100%;
`;
const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({
afterCaseCreated,
onCloseFlyout,
onSuccess,
owner,
disableAlerts,
}) => {
const { cases } = useKibana<TimelinesStartServices>().services;
const createCaseProps = useMemo(() => {
return {
afterCaseCreated,
onCancel: onCloseFlyout,
onSuccess,
withSteps: false,
owner: [owner],
disableAlerts,
};
}, [afterCaseCreated, onCloseFlyout, onSuccess, owner, disableAlerts]);
return (
<>
<GlobalStyle />
<StyledFlyout
onClose={onCloseFlyout}
data-test-subj="create-case-flyout"
maskProps={{ className: maskOverlayClassName }}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{i18n.CREATE_TITLE}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<StyledEuiFlyoutBody>
<FormWrapper>{cases.getCreateCase(createCaseProps)}</FormWrapper>
</StyledEuiFlyoutBody>
</StyledFlyout>
</>
);
};
export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent);
CreateCaseFlyout.displayName = 'CreateCaseFlyout';

View file

@ -73,7 +73,3 @@ export const UNSUPPORTED_EVENTS_MSG = i18n.translate(
defaultMessage: 'This event cannot be attached to a case',
}
);
export const CREATE_TITLE = i18n.translate('xpack.timelines.cases.caseView.create', {
defaultMessage: 'Create new case',
});

View file

@ -6,7 +6,6 @@
*/
import { get, isEmpty } from 'lodash/fp';
import { useState, useCallback, useMemo, SyntheticEvent } from 'react';
import { useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils/technical_field_names';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
@ -17,21 +16,18 @@ import { tGridActions } from '../store/t_grid';
import { useDeepEqualSelector } from './use_selector';
import { createUpdateSuccessToaster } from '../components/actions/timeline/cases/helpers';
import { AddToCaseActionProps } from '../components/actions';
import { CasesDeepLinkId, generateCaseViewPath } from '../../../cases/public';
interface UseAddToCase {
addNewCaseClick: () => void;
addExistingCaseClick: () => void;
onCaseClicked: (theCase?: Case | SubCase) => void;
goToCreateCase: (
arg: MouseEvent | React.MouseEvent<Element, MouseEvent> | null
) => void | Promise<void>;
onCaseSuccess: (theCase: Case) => Promise<void>;
attachAlertToCase: (
theCase: Case,
postComment?: ((arg: PostCommentArg) => Promise<void>) | undefined,
updateCase?: ((newCase: Case) => void) | undefined
) => Promise<void>;
createCaseUrl: string;
isAllCaseModalOpen: boolean;
isDisabled: boolean;
userCanCrud: boolean;
@ -42,27 +38,6 @@ interface UseAddToCase {
isCreateCaseFlyoutOpen: boolean;
}
const appendSearch = (search?: string) =>
isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`;
const getCreateCaseUrl = (search?: string | null) => `/create${appendSearch(search ?? undefined)}`;
const getCaseDetailsUrl = ({
id,
search,
subCaseId,
}: {
id: string;
search?: string | null;
subCaseId?: string;
}) => {
if (subCaseId) {
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(subCaseId)}${appendSearch(
search ?? undefined
)}`;
}
return `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
};
interface PostCommentArg {
caseId: string;
data: {
@ -78,7 +53,6 @@ interface PostCommentArg {
export const useAddToCase = ({
event,
useInsertTimeline,
casePermissions,
appId,
owner,
@ -111,42 +85,36 @@ export const useAddToCase = ({
}
}, [timelineById]);
const {
application: { navigateToApp, getUrlForApp, navigateToUrl },
application: { navigateToApp },
notifications: { toasts },
} = useKibana<TimelinesStartServices>().services;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const openPopover = useCallback(() => setIsPopoverOpen(true), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const isAlert = useMemo(() => {
const isEventSupported = useMemo(() => {
if (event !== undefined) {
const data = [...event.data];
return data.some(({ field }) => field === 'kibana.alert.rule.uuid');
if (event.data.some(({ field }) => field === 'kibana.alert.rule.uuid')) {
return true;
}
return !isEmpty(event.ecs.signal?.rule?.id ?? event.ecs.kibana?.alert?.rule?.uuid);
} else {
return false;
}
}, [event]);
const isSecurityAlert = useMemo(() => {
return !isEmpty(event?.ecs.signal?.rule?.id ?? event?.ecs.kibana?.alert?.rule?.uuid);
}, [event]);
const isEventSupported = isSecurityAlert || isAlert;
const userCanCrud = casePermissions?.crud ?? false;
const isDisabled = !userCanCrud || !isEventSupported;
const onViewCaseClick = useCallback(
(id) => {
const caseDetailsUrl = getCaseDetailsUrl({ id });
const appUrl = getUrlForApp(appId);
const fullCaseUrl = `${appUrl}/cases/${caseDetailsUrl}`;
navigateToUrl(fullCaseUrl);
navigateToApp(appId, {
deepLinkId: CasesDeepLinkId.cases,
path: generateCaseViewPath({ detailName: id }),
});
},
[navigateToUrl, appId, getUrlForApp]
);
const currentSearch = useLocation().search;
const urlSearch = useMemo(() => currentSearch, [currentSearch]);
const createCaseUrl = useMemo(
() => getUrlForApp('cases') + getCreateCaseUrl(urlSearch),
[getUrlForApp, urlSearch]
[navigateToApp, appId]
);
const attachAlertToCase = useCallback(
@ -184,17 +152,6 @@ export const useAddToCase = ({
[onViewCaseClick, toasts, dispatch, eventId]
);
const goToCreateCase = useCallback(
async (ev) => {
ev.preventDefault();
return navigateToApp(appId, {
deepLinkId: appId === 'securitySolutionUI' ? 'case' : 'cases',
path: getCreateCaseUrl(urlSearch),
});
},
[navigateToApp, urlSearch, appId]
);
const onCaseClicked = useCallback(
(theCase?: Case | SubCase) => {
/**
@ -228,10 +185,8 @@ export const useAddToCase = ({
addNewCaseClick,
addExistingCaseClick,
onCaseClicked,
goToCreateCase,
onCaseSuccess,
attachAlertToCase,
createCaseUrl,
isAllCaseModalOpen,
isDisabled,
userCanCrud,

View file

@ -13,6 +13,7 @@ import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/p
import { EuiTheme } from '../../../../../src/plugins/kibana_react/common';
import { CoreStart } from '../../../../../src/core/public';
export const mockGetCreateCaseFlyout = jest.fn();
export const mockGetAllCasesSelectorModal = jest.fn();
export const mockNavigateToApp = jest.fn();
@ -26,6 +27,7 @@ export const createStartServicesMock = (): CoreStart => {
getConfigureCases: jest.fn(),
getCreateCase: jest.fn(),
getRecentCases: jest.fn(),
getCreateCaseFlyout: mockGetCreateCaseFlyout,
getAllCasesSelectorModal: mockGetAllCasesSelectorModal,
},
application: {