diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..1b792503cf1c61 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderPage it renders 1`] = ` +
+ + + +

+ Test title + + +

+
+ + +
+ +

+ Test supplement +

+
+
+
+`; diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx new file mode 100644 index 00000000000000..83a70fd90d82b5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx @@ -0,0 +1,224 @@ +/* + * 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../mock'; +import { HeaderPage } from './index'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +describe('HeaderPage', () => { + const mount = useMountAppended(); + + test('it renders', () => { + const wrapper = shallow( + +

{'Test supplement'}

+
+ ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the title', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-title"]') + .first() + .exists() + ).toBe(true); + }); + + test('it renders the back link when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the back link when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the first subtitle when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-subtitle"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the first subtitle when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the second subtitle when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-subtitle-2"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the second subtitle when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle-2"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders supplements when children provided', () => { + const wrapper = mount( + + +

{'Test supplement'}

+
+
+ ); + + expect( + wrapper + .find('[data-test-subj="header-page-supplements"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render supplements when children not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-supplements"]') + .first() + .exists() + ).toBe(false); + }); + + test('it applies border styles when border is true', () => { + const wrapper = mount( + + + + ); + const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); + + expect(siemHeaderPage).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderPage).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it DOES NOT apply border styles when border is false', () => { + const wrapper = mount( + + + + ); + const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); + + expect(siemHeaderPage).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderPage).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it renders as a draggable when arguments provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-draggable"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render as a draggable when arguments not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-draggable"]') + .first() + .exists() + ).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx new file mode 100644 index 00000000000000..7e486c78fb9b95 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx @@ -0,0 +1,220 @@ +/* + * 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 { + EuiBadge, + EuiBetaBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { DefaultDraggable } from '../draggables'; +import { LinkIcon, LinkIconProps } from '../link_icon'; +import { Subtitle, SubtitleProps } from '../subtitle'; +import * as i18n from './translations'; + +interface HeaderProps { + border?: boolean; + isLoading?: boolean; +} + +const Header = styled.header.attrs({ + className: 'siemHeaderPage', +})` + ${({ border, theme }) => css` + margin-bottom: ${theme.eui.euiSizeL}; + + ${border && + css` + border-bottom: ${theme.eui.euiBorderThin}; + padding-bottom: ${theme.eui.paddingSizes.l}; + .euiProgress { + top: ${theme.eui.paddingSizes.l}; + } + `} + `} +`; +Header.displayName = 'Header'; + +const FlexItem = styled(EuiFlexItem)` + display: block; +`; +FlexItem.displayName = 'FlexItem'; + +const LinkBack = styled.div.attrs({ + className: 'siemHeaderPage__linkBack', +})` + ${({ theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + margin-bottom: ${theme.eui.euiSizeS}; + `} +`; +LinkBack.displayName = 'LinkBack'; + +const Badge = styled(EuiBadge)` + letter-spacing: 0; +`; +Badge.displayName = 'Badge'; + +const StyledEuiBetaBadge = styled(EuiBetaBadge)` + vertical-align: middle; +`; + +StyledEuiBetaBadge.displayName = 'StyledEuiBetaBadge'; + +const StyledEuiButtonIcon = styled(EuiButtonIcon)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; + +StyledEuiButtonIcon.displayName = 'StyledEuiButtonIcon'; + +interface BackOptions { + href: LinkIconProps['href']; + text: LinkIconProps['children']; +} + +interface BadgeOptions { + beta?: boolean; + text: string; + tooltip?: string; +} + +interface DraggableArguments { + field: string; + value: string; +} +interface IconAction { + 'aria-label': string; + iconType: string; + onChange: (a: string) => void; + onClick: (b: boolean) => void; + onSubmit: () => void; +} + +export interface HeaderPageProps extends HeaderProps { + backOptions?: BackOptions; + badgeOptions?: BadgeOptions; + children?: React.ReactNode; + draggableArguments?: DraggableArguments; + isEditTitle?: boolean; + iconAction?: IconAction; + subtitle2?: SubtitleProps['items']; + subtitle?: SubtitleProps['items']; + title: string | React.ReactNode; +} + +const HeaderPageComponent: React.FC = ({ + backOptions, + badgeOptions, + border, + children, + draggableArguments, + isEditTitle, + iconAction, + isLoading, + subtitle, + subtitle2, + title, + ...rest +}) => ( +
+ + + {backOptions && ( + + + {backOptions.text} + + + )} + + {isEditTitle && iconAction ? ( + + + iconAction.onChange(e.target.value)} + value={`${title}`} + /> + + + + + {i18n.SUBMIT} + + + + iconAction.onClick(false)}> + {i18n.CANCEL} + + + + + + ) : ( + +

+ {!draggableArguments ? ( + title + ) : ( + + )} + {badgeOptions && ( + <> + {' '} + {badgeOptions.beta ? ( + + ) : ( + {badgeOptions.text} + )} + + )} + {iconAction && ( + iconAction.onClick(true)} + /> + )} +

+
+ )} + + {subtitle && } + {subtitle2 && } + {border && isLoading && } +
+ + {children && {children}} +
+
+); + +export const HeaderPage = React.memo(HeaderPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts b/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts new file mode 100644 index 00000000000000..57b2cda0b0b016 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const SUBMIT = i18n.translate('xpack.siem.case.casePage.title.submit', { + defaultMessage: 'Submit', +}); + +export const CANCEL = i18n.translate('xpack.siem.case.casePage.title.cancel', { + defaultMessage: 'Cancel', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/index.ts b/x-pack/legacy/plugins/siem/public/components/link_to/index.ts index ad6147e5aad769..c93b415e017bb7 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/link_to/index.ts @@ -13,3 +13,10 @@ export { getOverviewUrl, RedirectToOverviewPage } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getIPDetailsUrl, RedirectToNetworkPage } from './redirect_to_network'; export { getTimelinesUrl, RedirectToTimelinesPage } from './redirect_to_timelines'; +export { + getCaseDetailsUrl, + getCaseUrl, + getCreateCaseUrl, + RedirectToCasePage, + RedirectToCreatePage, +} from './redirect_to_case'; diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx index dc8c696301611b..c08b429dc4625e 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx @@ -20,6 +20,7 @@ import { RedirectToHostsPage, RedirectToHostDetailsPage } from './redirect_to_ho import { RedirectToNetworkPage } from './redirect_to_network'; import { RedirectToOverviewPage } from './redirect_to_overview'; import { RedirectToTimelinesPage } from './redirect_to_timelines'; +import { RedirectToCasePage, RedirectToCreatePage } from './redirect_to_case'; import { DetectionEngineTab } from '../../pages/detection_engine/types'; interface LinkToPageProps { @@ -32,6 +33,20 @@ export const LinkToPage = React.memo(({ match }) => ( component={RedirectToOverviewPage} path={`${match.url}/:pageName(${SiemPageName.overview})`} /> + + + ; + +export const RedirectToCasePage = ({ + match: { + params: { detailName }, + }, +}: CaseComponentProps) => ( + +); + +export const RedirectToCreatePage = () => ; + +const baseCaseUrl = `#/link-to/${SiemPageName.case}`; + +export const getCaseUrl = () => baseCaseUrl; +export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; +export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index e122b3e235a9e5..4f74f9ff2f5d60 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -8,7 +8,12 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; import { encodeIpv6 } from '../../lib/helpers'; -import { getHostDetailsUrl, getIPDetailsUrl } from '../link_to'; +import { + getCaseDetailsUrl, + getHostDetailsUrl, + getIPDetailsUrl, + getCreateCaseUrl, +} from '../link_to'; import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; // Internal Links @@ -35,6 +40,23 @@ const IPDetailsLinkComponent: React.FC<{ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); +const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ + children, + detailName, +}) => ( + + {children ? children : detailName} + +); +export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); +CaseDetailsLink.displayName = 'CaseDetailsLink'; + +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( + {children} +)); + +CreateCaseLink.displayName = 'CreateCaseLink'; + // External Links export const GoogleLink = React.memo<{ children?: React.ReactNode; link: string }>( ({ children, link }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e8d5032fd75480..e25fb4374bb14e 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -11,6 +11,7 @@ import { APP_NAME } from '../../../../common/constants'; import { StartServices } from '../../../plugin'; import { getBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../pages/hosts/details/utils'; import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../pages/network/ip_details'; +import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../pages/case/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../pages/detection_engine/rules/utils'; import { SiemPageName } from '../../../pages/home/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState } from '../../../utils/route/types'; @@ -43,6 +44,9 @@ const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpySt const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => spyState != null && spyState.pageName === SiemPageName.hosts; +const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => + spyState != null && spyState.pageName === SiemPageName.case; + const isDetectionsRoutes = (spyState: RouteSpyState) => spyState != null && spyState.pageName === SiemPageName.detections; @@ -102,6 +106,9 @@ export const getBreadcrumbsForRoute = ( ), ]; } + if (isCaseRoutes(spyState) && object.navTabs) { + return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + } if ( spyState != null && object.navTabs && diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index ac7a4a0ee52b75..8eb08bd3d62f04 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -66,6 +66,13 @@ describe('SIEM Navigation', () => { { detailName: undefined, navTabs: { + case: { + disabled: true, + href: '#/link-to/case', + id: 'case', + name: 'Case', + urlKey: 'case', + }, detections: { disabled: false, href: '#/link-to/detections', @@ -152,6 +159,13 @@ describe('SIEM Navigation', () => { detailName: undefined, filters: [], navTabs: { + case: { + disabled: true, + href: '#/link-to/case', + id: 'case', + name: 'Case', + urlKey: 'case', + }, detections: { disabled: false, href: '#/link-to/detections', diff --git a/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx b/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx index 5a3439d53dd891..15e58f3efd21ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx @@ -32,8 +32,6 @@ const TextArea = styled(EuiTextArea)<{ height: number }>` TextArea.displayName = 'TextArea'; -TextArea.displayName = 'TextArea'; - /** An input for entering a new note */ export const NewNote = React.memo<{ noteInputHeight: number; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts b/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts index 22e8f99658f8d5..b6ef3c8ccd4e9e 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts @@ -6,6 +6,8 @@ export enum CONSTANTS { appQuery = 'query', + caseDetails = 'case.details', + casePage = 'case.page', detectionsPage = 'detections.page', filters = 'filters', hostsDetails = 'hosts.details', @@ -14,10 +16,10 @@ export enum CONSTANTS { networkPage = 'network.page', overviewPage = 'overview.page', savedQuery = 'savedQuery', + timeline = 'timeline', timelinePage = 'timeline.page', timerange = 'timerange', - timeline = 'timeline', unknown = 'unknown', } -export type UrlStateType = 'detections' | 'host' | 'network' | 'overview' | 'timeline'; +export type UrlStateType = 'case' | 'detections' | 'host' | 'network' | 'overview' | 'timeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts b/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts index 7be775ef0c0e48..05329621aa97a1 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts @@ -98,6 +98,8 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'detections'; } else if (pageName === SiemPageName.timelines) { return 'timeline'; + } else if (pageName === SiemPageName.case) { + return 'case'; } return 'overview'; }; @@ -131,6 +133,11 @@ export const getCurrentLocation = ( return CONSTANTS.detectionsPage; } else if (pageName === SiemPageName.timelines) { return CONSTANTS.timelinePage; + } else if (pageName === SiemPageName.case) { + if (detailName != null) { + return CONSTANTS.caseDetails; + } + return CONSTANTS.casePage; } return CONSTANTS.unknown; }; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index fea1bc016fd49b..97979e514aeaf1 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,9 +60,12 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timeline, ], timeline: [CONSTANTS.timeline, CONSTANTS.timerange], + case: [], }; export type LocationTypes = + | CONSTANTS.caseDetails + | CONSTANTS.casePage | CONSTANTS.detectionsPage | CONSTANTS.hostsDetails | CONSTANTS.hostsPage diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts new file mode 100644 index 00000000000000..830e00c70975e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -0,0 +1,73 @@ +/* + * 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 { KibanaServices } from '../../lib/kibana'; +import { AllCases, FetchCasesProps, Case, NewCase, SortFieldCase } from './types'; +import { Direction } from '../../graphql/types'; +import { throwIfNotOk } from '../../hooks/api/api'; +import { CASES_URL } from './constants'; + +export const getCase = async (caseId: string, includeComments: boolean) => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + method: 'GET', + asResponse: true, + query: { + includeComments, + }, + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const getCases = async ({ + filterOptions = { + search: '', + tags: [], + }, + queryParams = { + page: 1, + perPage: 20, + sortField: SortFieldCase.createdAt, + sortOrder: Direction.desc, + }, +}: FetchCasesProps): Promise => { + const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])]; + const query = { + ...queryParams, + filter: tags.join(' AND '), + search: filterOptions.search, + }; + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + method: 'GET', + query, + asResponse: true, + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const createCase = async (newCase: NewCase): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + method: 'POST', + asResponse: true, + body: JSON.stringify(newCase), + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const updateCaseProperty = async ( + caseId: string, + updatedCase: Partial +): Promise> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + method: 'PATCH', + asResponse: true, + body: JSON.stringify(updatedCase), + }); + await throwIfNotOk(response.response); + return response.body!; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts new file mode 100644 index 00000000000000..c8d668527ae329 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +export const CASES_URL = `/api/cases`; +export const DEFAULT_TABLE_ACTIVE_PAGE = 1; +export const DEFAULT_TABLE_LIMIT = 5; +export const FETCH_FAILURE = 'FETCH_FAILURE'; +export const FETCH_INIT = 'FETCH_INIT'; +export const FETCH_SUCCESS = 'FETCH_SUCCESS'; +export const POST_NEW_CASE = 'POST_NEW_CASE'; +export const UPDATE_CASE_PROPERTY = 'UPDATE_CASE_PROPERTY'; +export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS'; +export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts new file mode 100644 index 00000000000000..0c8b896e2b426b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -0,0 +1,18 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ERROR_TITLE = i18n.translate('xpack.siem.containers.case.errorTitle', { + defaultMessage: 'Error fetching data', +}); + +export const TAG_FETCH_FAILURE = i18n.translate( + 'xpack.siem.containers.case.tagFetchFailDescription', + { + defaultMessage: 'Failed to fetch Tags', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts new file mode 100644 index 00000000000000..0f80b2327a30c3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -0,0 +1,61 @@ +/* + * 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 { Direction } from '../../graphql/types'; +interface FormData { + isNew?: boolean; +} + +export interface NewCase extends FormData { + description: string; + tags: string[]; + title: string; +} + +export interface Case { + case_id: string; + created_at: string; + created_by: ElasticUser; + description: string; + state: string; + tags: string[]; + title: string; + updated_at: string; +} + +export interface QueryParams { + page: number; + perPage: number; + sortField: SortFieldCase; + sortOrder: Direction; +} + +export interface FilterOptions { + search: string; + tags: string[]; +} + +export interface AllCases { + cases: Case[]; + page: number; + per_page: number; + total: number; +} +export enum SortFieldCase { + createdAt = 'created_at', + state = 'state', + updatedAt = 'updated_at', +} + +export interface ElasticUser { + readonly username: string; + readonly full_name?: string; +} + +export interface FetchCasesProps { + queryParams?: QueryParams; + filterOptions?: FilterOptions; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx new file mode 100644 index 00000000000000..8cc961c68fdf07 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -0,0 +1,99 @@ +/* + * 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 { useEffect, useReducer } from 'react'; + +import { Case } from './types'; +import { FETCH_INIT, FETCH_FAILURE, FETCH_SUCCESS } from './constants'; +import { getTypedPayload } from './utils'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { useStateToaster } from '../../components/toasters'; +import { getCase } from './api'; + +interface CaseState { + data: Case; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: Case; +} + +const dataFetchReducer = (state: CaseState, action: Action): CaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: Case = { + case_id: '', + created_at: '', + created_by: { + username: '', + }, + description: '', + state: '', + tags: [], + title: '', + updated_at: '', +}; + +export const useGetCase = (caseId: string): [CaseState] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: true, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + const callFetch = () => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await getCase(caseId, false); + if (!didCancel) { + dispatch({ type: FETCH_SUCCESS, payload: response }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }; + + useEffect(() => { + callFetch(); + }, [caseId]); + return [state]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx new file mode 100644 index 00000000000000..db9c07747ba044 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -0,0 +1,154 @@ +/* + * 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 { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { isEqual } from 'lodash/fp'; +import { + DEFAULT_TABLE_ACTIVE_PAGE, + DEFAULT_TABLE_LIMIT, + FETCH_FAILURE, + FETCH_INIT, + FETCH_SUCCESS, + UPDATE_QUERY_PARAMS, + UPDATE_FILTER_OPTIONS, +} from './constants'; +import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types'; +import { getTypedPayload } from './utils'; +import { Direction } from '../../graphql/types'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import { useStateToaster } from '../../components/toasters'; +import * as i18n from './translations'; +import { getCases } from './api'; + +export interface UseGetCasesState { + data: AllCases; + isLoading: boolean; + isError: boolean; + queryParams: QueryParams; + filterOptions: FilterOptions; +} + +export interface QueryArgs { + page?: number; + perPage?: number; + sortField?: SortFieldCase; + sortOrder?: Direction; +} + +export interface Action { + type: string; + payload?: AllCases | QueryArgs | FilterOptions; +} +const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + case UPDATE_QUERY_PARAMS: + return { + ...state, + queryParams: { + ...state.queryParams, + ...action.payload, + }, + }; + case UPDATE_FILTER_OPTIONS: + return { + ...state, + filterOptions: getTypedPayload(action.payload), + }; + default: + throw new Error(); + } +}; + +const initialData: AllCases = { + page: 0, + per_page: 0, + total: 0, + cases: [], +}; +export const useGetCases = (): [ + UseGetCasesState, + Dispatch>, + Dispatch> +] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + filterOptions: { + search: '', + tags: [], + }, + queryParams: { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: Direction.desc, + }, + }); + const [queryParams, setQueryParams] = useState(state.queryParams as QueryArgs); + const [filterQuery, setFilters] = useState(state.filterOptions as FilterOptions); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + if (!isEqual(queryParams, state.queryParams)) { + dispatch({ type: UPDATE_QUERY_PARAMS, payload: queryParams }); + } + }, [queryParams, state.queryParams]); + + useEffect(() => { + if (!isEqual(filterQuery, state.filterOptions)) { + dispatch({ type: UPDATE_FILTER_OPTIONS, payload: filterQuery }); + } + }, [filterQuery, state.filterOptions]); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await getCases({ + filterOptions: state.filterOptions, + queryParams: state.queryParams, + }); + if (!didCancel) { + dispatch({ + type: FETCH_SUCCESS, + payload: response, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, [state.queryParams, state.filterOptions]); + return [state, setQueryParams, setFilters]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx new file mode 100644 index 00000000000000..f796ae550c9ec3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useEffect, useReducer } from 'react'; +import chrome from 'ui/chrome'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import { throwIfNotOk } from '../../hooks/api/api'; + +interface TagsState { + data: string[]; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: string[]; +} + +const dataFetchReducer = (state: TagsState, action: Action): TagsState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + const getTypedPayload = (a: Action['payload']) => a as string[]; + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: string[] = []; + +export const useGetTags = (): [TagsState] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await fetch(`${chrome.getBasePath()}/api/cases/tags`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-system-api': 'true', + }, + }); + if (!didCancel) { + await throwIfNotOk(response); + const responseJson = await response.json(); + dispatch({ type: FETCH_SUCCESS, payload: responseJson }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, []); + return [state]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx new file mode 100644 index 00000000000000..5cf99701977d27 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -0,0 +1,97 @@ +/* + * 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 { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_CASE } from './constants'; +import { Case, NewCase } from './types'; +import { createCase } from './api'; +import { getTypedPayload } from './utils'; + +interface NewCaseState { + data: NewCase; + newCase?: Case; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: NewCase | Case; +} + +const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case POST_NEW_CASE: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + newCase: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: NewCase = { + description: '', + isNew: false, + tags: [], + title: '', +}; + +export const usePostCase = (): [NewCaseState, Dispatch>] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [formData, setFormData] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + dispatch({ type: POST_NEW_CASE, payload: formData }); + }, [formData]); + + useEffect(() => { + const postCase = async () => { + dispatch({ type: FETCH_INIT }); + try { + const dataWithoutIsNew = state.data; + delete dataWithoutIsNew.isNew; + const response = await createCase(dataWithoutIsNew); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.data.isNew) { + postCase(); + } + }, [state.data.isNew]); + return [state, setFormData]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx new file mode 100644 index 00000000000000..68592c17e58dcc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -0,0 +1,112 @@ +/* + * 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 { useEffect, useReducer } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, UPDATE_CASE_PROPERTY } from './constants'; +import { Case } from './types'; +import { updateCaseProperty } from './api'; +import { getTypedPayload } from './utils'; + +type UpdateKey = keyof Case; + +interface NewCaseState { + data: Case; + isLoading: boolean; + isError: boolean; + updateKey?: UpdateKey | null; +} + +interface UpdateByKey { + updateKey: UpdateKey; + updateValue: Case[UpdateKey]; +} + +interface Action { + type: string; + payload?: Partial | UpdateByKey; +} + +const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + updateKey: null, + }; + case UPDATE_CASE_PROPERTY: + const { updateKey, updateValue } = getTypedPayload(action.payload); + return { + ...state, + isLoading: false, + isError: false, + data: { + ...state.data, + [updateKey]: updateValue, + }, + updateKey, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: { + ...state.data, + ...getTypedPayload(action.payload), + }, + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; + +export const useUpdateCase = ( + caseId: string, + initialData: Case +): [{ data: Case }, (updates: UpdateByKey) => void] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + const dispatchUpdateCaseProperty = ({ updateKey, updateValue }: UpdateByKey) => { + dispatch({ + type: UPDATE_CASE_PROPERTY, + payload: { updateKey, updateValue }, + }); + }; + + useEffect(() => { + const updateData = async (updateKey: keyof Case) => { + dispatch({ type: FETCH_INIT }); + try { + const response = await updateCaseProperty(caseId, { [updateKey]: state.data[updateKey] }); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.updateKey) { + updateData(state.updateKey); + } + }, [state.updateKey]); + + return [{ data: state.data }, dispatchUpdateCaseProperty]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts new file mode 100644 index 00000000000000..8e6eaca1a8f0cf --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const getTypedPayload = (a: unknown): T => a as T; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx new file mode 100644 index 00000000000000..1206ec950deed1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx @@ -0,0 +1,39 @@ +/* + * 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 React from 'react'; + +import { EuiButton, EuiFlexGroup } from '@elastic/eui'; +import { HeaderPage } from '../../components/header_page'; +import { WrapperPage } from '../../components/wrapper_page'; +import { AllCases } from './components/all_cases'; +import { SpyRoute } from '../../utils/route/spy_routes'; +import * as i18n from './translations'; +import { getCreateCaseUrl } from '../../components/link_to'; + +const badgeOptions = { + beta: true, + text: i18n.PAGE_BADGE_LABEL, + tooltip: i18n.PAGE_BADGE_TOOLTIP, +}; + +export const CasesPage = React.memo(() => ( + <> + + + + + {i18n.CREATE_TITLE} + + + + + + + +)); + +CasesPage.displayName = 'CasesPage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx new file mode 100644 index 00000000000000..890df91c8560eb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx @@ -0,0 +1,26 @@ +/* + * 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 React from 'react'; +import { useParams } from 'react-router-dom'; + +import { CaseView } from './components/case_view'; +import { SpyRoute } from '../../utils/route/spy_routes'; + +export const CaseDetailsPage = React.memo(() => { + const { detailName: caseId } = useParams(); + if (!caseId) { + return null; + } + return ( + <> + + + + ); +}); + +CaseDetailsPage.displayName = 'CaseDetailsPage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx new file mode 100644 index 00000000000000..92cd16fd2000ed --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -0,0 +1,81 @@ +/* + * 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 React from 'react'; +import { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType } from '@elastic/eui'; +import { getEmptyTagValue } from '../../../../components/empty_value'; +import { Case } from '../../../../containers/case/types'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseDetailsLink } from '../../../../components/links'; +import { TruncatableText } from '../../../../components/truncatable_text'; +import * as i18n from './translations'; + +export type CasesColumns = EuiTableFieldDataColumnType | EuiTableComputedColumnType; + +const renderStringField = (field: string) => (field != null ? field : getEmptyTagValue()); + +export const getCasesColumns = (): CasesColumns[] => [ + { + name: i18n.CASE_TITLE, + render: (theCase: Case) => { + if (theCase.case_id != null && theCase.title != null) { + return {theCase.title}; + } + return getEmptyTagValue(); + }, + }, + { + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + return ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + } + return getEmptyTagValue(); + }, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.CREATED_AT, + sortable: true, + render: (createdAt: Case['created_at']) => { + if (createdAt != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + { + field: 'created_by.username', + name: i18n.REPORTER, + render: (createdBy: Case['created_by']['username']) => renderStringField(createdBy), + }, + { + field: 'updated_at', + name: i18n.LAST_UPDATED, + sortable: true, + render: (updatedAt: Case['updated_at']) => { + if (updatedAt != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + { + field: 'state', + name: i18n.STATE, + sortable: true, + render: (state: Case['state']) => renderStringField(state), + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx new file mode 100644 index 00000000000000..b1dd39c95e1910 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -0,0 +1,147 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import { + EuiBasicTable, + EuiButton, + EuiEmptyPrompt, + EuiLoadingContent, + EuiTableSortingType, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import * as i18n from './translations'; + +import { getCasesColumns } from './columns'; +import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types'; + +import { Direction } from '../../../../graphql/types'; +import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { Panel } from '../../../../components/panel'; +import { HeaderSection } from '../../../../components/header_section'; +import { CasesTableFilters } from './table_filters'; + +import { + UtilityBar, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from '../../../../components/detection_engine/utility_bar'; +import { getCreateCaseUrl } from '../../../../components/link_to'; + +export const AllCases = React.memo(() => { + const [ + { data, isLoading, queryParams, filterOptions }, + setQueryParams, + setFilters, + ] = useGetCases(); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + let newQueryParams = queryParams; + if (sort) { + let newSort; + switch (sort.field) { + case 'state': + newSort = SortFieldCase.state; + break; + case 'created_at': + newSort = SortFieldCase.createdAt; + break; + case 'updated_at': + newSort = SortFieldCase.updatedAt; + break; + default: + newSort = SortFieldCase.createdAt; + } + newQueryParams = { + ...newQueryParams, + sortField: newSort, + sortOrder: sort.direction as Direction, + }; + } + if (page) { + newQueryParams = { + ...newQueryParams, + page: page.index + 1, + perPage: page.size, + }; + } + setQueryParams(newQueryParams); + }, + [setQueryParams, queryParams] + ); + + const onFilterChangedCallback = useCallback( + (newFilterOptions: Partial) => { + setFilters({ ...filterOptions, ...newFilterOptions }); + }, + [filterOptions, setFilters] + ); + + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(), []); + const memoizedPagination = useMemo( + () => ({ + pageIndex: queryParams.page - 1, + pageSize: queryParams.perPage, + totalItemCount: data.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [data, queryParams] + ); + + const sorting: EuiTableSortingType = { + sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, + }; + + return ( + + + + + {isLoading && isEmpty(data.cases) && ( + + )} + {!isLoading && !isEmpty(data.cases) && ( + <> + + + + {i18n.SHOWING_CASES(data.total ?? 0)} + + + + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + + {i18n.ADD_NEW_CASE} + + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + sorting={sorting} + /> + + )} + + ); +}); + +AllCases.displayName = 'AllCases'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx new file mode 100644 index 00000000000000..e593623788046a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx @@ -0,0 +1,90 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import { isEqual } from 'lodash/fp'; +import { EuiFieldSearch, EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +import { FilterOptions } from '../../../../containers/case/types'; +import { useGetTags } from '../../../../containers/case/use_get_tags'; +import { TagsFilterPopover } from '../../../../pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover'; + +interface Initial { + search: string; + tags: string[]; +} +interface CasesTableFiltersProps { + onFilterChanged: (filterOptions: Partial) => void; + initial: Initial; +} + +/** + * Collection of filters for filtering data within the CasesTable. Contains search bar, + * and tag selection + * + * @param onFilterChanged change listener to be notified on filter changes + */ + +const CasesTableFiltersComponent = ({ + onFilterChanged, + initial = { search: '', tags: [] }, +}: CasesTableFiltersProps) => { + const [search, setSearch] = useState(initial.search); + const [selectedTags, setSelectedTags] = useState(initial.tags); + const [{ isLoading, data }] = useGetTags(); + + const handleSelectedTags = useCallback( + newTags => { + if (!isEqual(newTags, selectedTags)) { + setSelectedTags(newTags); + onFilterChanged({ search, tags: newTags }); + } + }, + [search, selectedTags] + ); + const handleOnSearch = useCallback( + newSearch => { + const trimSearch = newSearch.trim(); + if (!isEqual(trimSearch, search)) { + setSearch(trimSearch); + onFilterChanged({ tags: selectedTags, search: trimSearch }); + } + }, + [search, selectedTags] + ); + + return ( + + + + + + + + + + + + ); +}; + +CasesTableFiltersComponent.displayName = 'CasesTableFiltersComponent'; + +export const CasesTableFilters = React.memo(CasesTableFiltersComponent); + +CasesTableFilters.displayName = 'CasesTableFilters'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts new file mode 100644 index 00000000000000..ab8e22ebcf1beb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -0,0 +1,48 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const ALL_CASES = i18n.translate('xpack.siem.case.caseTable.title', { + defaultMessage: 'All Cases', +}); +export const NO_CASES = i18n.translate('xpack.siem.case.caseTable.noCases.title', { + defaultMessage: 'No Cases', +}); +export const NO_CASES_BODY = i18n.translate('xpack.siem.case.caseTable.noCases.body', { + defaultMessage: 'Create a new case to see it displayed in the case workflow table.', +}); +export const ADD_NEW_CASE = i18n.translate('xpack.siem.case.caseTable.addNewCase', { + defaultMessage: 'Add New Case', +}); + +export const SHOWING_CASES = (totalRules: number) => + i18n.translate('xpack.siem.case.caseTable.showingCasesTitle', { + values: { totalRules }, + defaultMessage: 'Showing {totalRules} {totalRules, plural, =1 {case} other {cases}}', + }); + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.siem.case.caseTable.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`, + }); + +export const SEARCH_CASES = i18n.translate( + 'xpack.siem.detectionEngine.case.caseTable.searchAriaLabel', + { + defaultMessage: 'Search cases', + } +); + +export const SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.siem.detectionEngine.case.caseTable.searchPlaceholder', + { + defaultMessage: 'e.g. case name', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx new file mode 100644 index 00000000000000..4f43a6edeeac6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -0,0 +1,312 @@ +/* + * 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 React, { useCallback, useEffect, useState } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import styled, { css } from 'styled-components'; +import * as i18n from './translations'; +import { DescriptionMarkdown } from '../description_md_editor'; +import { Case } from '../../../../containers/case/types'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { getCaseUrl } from '../../../../components/link_to'; +import { HeaderPage } from '../../../../components/header_page_new'; +import { Markdown } from '../../../../components/markdown'; +import { PropertyActions } from '../property_actions'; +import { TagList } from '../tag_list'; +import { useGetCase } from '../../../../containers/case/use_get_case'; +import { UserActionTree } from '../user_action_tree'; +import { UserList } from '../user_list'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { WrapperPage } from '../../../../components/wrapper_page'; + +interface Props { + caseId: string; +} + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +const MyWrapper = styled(WrapperPage)` + padding-bottom: 0; +`; +const BackgroundWrapper = styled.div` + ${({ theme }) => css` + background-color: ${theme.eui.euiColorEmptyShade}; + border-top: ${theme.eui.euiBorderThin}; + height: 100%; + `} +`; + +interface CasesProps { + caseId: string; + initialData: Case; + isLoading: boolean; +} + +export const Cases = React.memo(({ caseId, initialData, isLoading }) => { + const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); + const [isEditDescription, setIsEditDescription] = useState(false); + const [isEditTitle, setIsEditTitle] = useState(false); + const [isEditTags, setIsEditTags] = useState(false); + const [isCaseOpen, setIsCaseOpen] = useState(data.state === 'open'); + const [description, setDescription] = useState(data.description); + const [title, setTitle] = useState(data.title); + const [tags, setTags] = useState(data.tags); + + const onUpdateField = useCallback( + async (updateKey: keyof Case, updateValue: string | string[]) => { + switch (updateKey) { + case 'title': + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'title', + updateValue, + }); + setIsEditTitle(false); + } + break; + case 'description': + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'description', + updateValue, + }); + setIsEditDescription(false); + } + break; + case 'tags': + setTags(updateValue as string[]); + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'tags', + updateValue, + }); + setIsEditTags(false); + } + break; + default: + return null; + } + }, + [dispatchUpdateCaseProperty, title] + ); + + const onSetIsCaseOpen = useCallback(() => setIsCaseOpen(!isCaseOpen), [ + isCaseOpen, + setIsCaseOpen, + ]); + + useEffect(() => { + const caseState = isCaseOpen ? 'open' : 'closed'; + if (data.state !== caseState) { + dispatchUpdateCaseProperty({ + updateKey: 'state', + updateValue: caseState, + }); + } + }, [isCaseOpen]); + + // TO DO refactor each of these const's into their own components + const propertyActions = [ + { + iconType: 'documentEdit', + label: 'Edit description', + onClick: () => setIsEditDescription(true), + }, + { + iconType: 'securitySignalResolved', + label: 'Close case', + onClick: () => null, + }, + { + iconType: 'trash', + label: 'Delete case', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Push as ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ]; + const userActions = [ + { + avatarName: data.created_by.username, + title: ( + + +

+ {`${data.created_by.username}`} + {` ${i18n.ADDED_DESCRIPTION} `}{' '} + + {/* STEPH FIX come back and add label `on` */} +

+
+ + + +
+ ), + children: isEditDescription ? ( + <> + setDescription(updatedDescription)} + /> + + + + onUpdateField('description', description)} + > + {i18n.SUBMIT} + + + + setIsEditDescription(false)}> + {i18n.CANCEL} + + + + + ) : ( + + ), + }, + ]; + return ( + <> + + setTitle(newTitle), + onSubmit: () => onUpdateField('title', title), + onClick: isEdit => setIsEditTitle(isEdit), + }} + isEditTitle={isEditTitle} + title={title} + > + + + + + + {i18n.STATUS} + + {data.state} + + + + {i18n.CASE_OPENED} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + onUpdateField('tags', newTags), + onClick: isEdit => setIsEditTags(isEdit), + }} + isEditTags={isEditTags} + /> + + + + + + ); +}); + +export const CaseView = React.memo(({ caseId }: Props) => { + const [{ data, isLoading, isError }] = useGetCase(caseId); + if (isError) { + return null; + } + if (isLoading) { + return ( + + + + + + ); + } + + return ; +}); + +CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts new file mode 100644 index 00000000000000..f45c52533d2e7b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -0,0 +1,45 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const SHOWING_CASES = (actionDate: string, actionName: string, userName: string) => + i18n.translate('xpack.siem.case.caseView.actionHeadline', { + values: { + actionDate, + actionName, + userName, + }, + defaultMessage: '{userName} {actionName} on {actionDate}', + }); + +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', + { + defaultMessage: 'added description', + } +); + +export const EDITED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.editDescription', + { + defaultMessage: 'edited description', + } +); + +export const ADDED_COMMENT = i18n.translate('xpack.siem.case.caseView.actionLabel.addComment', { + defaultMessage: 'added comment', +}); + +export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { + defaultMessage: 'Status', +}); + +export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { + defaultMessage: 'Case opened', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts new file mode 100644 index 00000000000000..7bc43e23a72c5e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +export const stateOptions = [ + { + value: 'open', + inputDisplay: 'Open', + }, + { + value: 'closed', + inputDisplay: 'Closed', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx new file mode 100644 index 00000000000000..9fd1525003b0b7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -0,0 +1,110 @@ +/* + * 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 React, { useCallback } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiPanel, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { Redirect } from 'react-router-dom'; +import { Field, Form, getUseField, useForm } from '../../../shared_imports'; +import { NewCase } from '../../../../containers/case/types'; +import { usePostCase } from '../../../../containers/case/use_post_case'; +import { schema } from './schema'; +import * as i18n from '../../translations'; +import { SiemPageName } from '../../../home/types'; +import { DescriptionMarkdown } from '../description_md_editor'; + +export const CommonUseField = getUseField({ component: Field }); + +const TagContainer = styled.div` + margin-top: 16px; +`; +const MySpinner = styled(EuiLoadingSpinner)` + position: absolute; + top: 50%; + left: 50%; +`; + +export const Create = React.memo(() => { + const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const { form } = useForm({ + defaultValue: data, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid) { + setFormData({ ...newData, isNew: true } as NewCase); + } + }, [form]); + + if (newCase && newCase.case_id) { + return ; + } + return ( + + {isLoading && } +
+ + setFormData({ ...data, description })} + /> + + + + + <> + + + + + {i18n.SUBMIT} + + + + +
+ ); +}); + +Create.displayName = 'Create'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx new file mode 100644 index 00000000000000..b86198e09ceac4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx @@ -0,0 +1,16 @@ +/* + * 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 { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../../../translations'; + +export const OptionalFieldLabel = ( + + {i18n.OPTIONAL} + +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx new file mode 100644 index 00000000000000..1b5df72a6671ca --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -0,0 +1,37 @@ +/* + * 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 { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; +import { OptionalFieldLabel } from './optional_field_label'; +import * as i18n from '../../translations'; + +const { emptyField } = fieldValidators; + +export const schema: FormSchema = { + title: { + type: FIELD_TYPES.TEXT, + label: i18n.CASE_TITLE, + validations: [ + { + validator: emptyField(i18n.TITLE_REQUIRED), + }, + ], + }, + description: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.DESCRIPTION_REQUIRED), + }, + ], + }, + tags: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx new file mode 100644 index 00000000000000..44062a5a1d5897 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx @@ -0,0 +1,111 @@ +/* + * 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 { EuiFlexItem, EuiPanel, EuiTabbedContent, EuiTextArea } from '@elastic/eui'; +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import { Markdown } from '../../../../components/markdown'; +import * as i18n from '../../translations'; +import { MarkdownHint } from '../../../../components/markdown/markdown_hint'; +import { CommonUseField } from '../create'; + +const TextArea = styled(EuiTextArea)<{ height: number }>` + min-height: ${({ height }) => `${height}px`}; + width: 100%; +`; + +TextArea.displayName = 'TextArea'; + +const DescriptionContainer = styled.div` + margin-top: 15px; + margin-bottom: 15px; +`; + +const DescriptionMarkdownTabs = styled(EuiTabbedContent)` + width: 100%; +`; + +DescriptionMarkdownTabs.displayName = 'DescriptionMarkdownTabs'; + +const MarkdownContainer = styled(EuiPanel)<{ height: number }>` + height: ${({ height }) => height}px; + overflow: auto; +`; + +MarkdownContainer.displayName = 'MarkdownContainer'; + +/** An input for entering a new case description */ +export const DescriptionMarkdown = React.memo<{ + descriptionInputHeight: number; + initialDescription: string; + isLoading: boolean; + formHook?: boolean; + onChange: (description: string) => void; +}>(({ initialDescription, isLoading, descriptionInputHeight, onChange, formHook = false }) => { + const [description, setDescription] = useState(initialDescription); + const tabs = [ + { + id: 'description', + name: i18n.DESCRIPTION, + content: formHook ? ( + { + setDescription(e as string); + onChange(e as string); + }} + componentProps={{ + idAria: 'caseDescription', + 'data-test-subj': 'caseDescription', + isDisabled: isLoading, + spellcheck: false, + }} + /> + ) : ( +