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 000000000000..1b792503cf1c --- /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`] = ` +<Header + border={true} +> + <EuiFlexGroup + alignItems="center" + justifyContent="spaceBetween" + > + <FlexItem + grow={false} + > + <EuiTitle + size="l" + > + <h1 + data-test-subj="header-page-title" + > + Test title + + <StyledEuiBetaBadge + label="Beta" + tooltipContent="Test tooltip" + tooltipPosition="bottom" + /> + </h1> + </EuiTitle> + <Subtitle + data-test-subj="header-page-subtitle" + items="Test subtitle" + /> + <Subtitle + data-test-subj="header-page-subtitle-2" + items="Test subtitle 2" + /> + </FlexItem> + <FlexItem + data-test-subj="header-page-supplements" + > + <p> + Test supplement + </p> + </FlexItem> + </EuiFlexGroup> +</Header> +`; 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 000000000000..83a70fd90d82 --- /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( + <HeaderPage + badgeOptions={{ beta: true, text: 'Beta', tooltip: 'Test tooltip' }} + border + subtitle="Test subtitle" + subtitle2="Test subtitle 2" + title="Test title" + > + <p>{'Test supplement'}</p> + </HeaderPage> + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the title', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect( + wrapper + .find('[data-test-subj="header-page-title"]') + .first() + .exists() + ).toBe(true); + }); + + test('it renders the back link when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage backOptions={{ href: '#', text: 'Test link' }} title="Test title" /> + </TestProviders> + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the back link when not provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the first subtitle when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage subtitle="Test subtitle" title="Test title" /> + </TestProviders> + ); + + 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( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the second subtitle when provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage subtitle2="Test subtitle 2" title="Test title" /> + </TestProviders> + ); + + 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( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle-2"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders supplements when children provided', () => { + const wrapper = mount( + <TestProviders> + <HeaderPage title="Test title"> + <p>{'Test supplement'}</p> + </HeaderPage> + </TestProviders> + ); + + 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( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + 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( + <TestProviders> + <HeaderPage border title="Test title" /> + </TestProviders> + ); + 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( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + 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( + <TestProviders> + <HeaderPage draggableArguments={{ field: 'neat', value: 'cool' }} title="Test title" /> + </TestProviders> + ); + + 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( + <TestProviders> + <HeaderPage title="Test title" /> + </TestProviders> + ); + + 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 000000000000..7e486c78fb9b --- /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', +})<HeaderProps>` + ${({ 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<HeaderPageProps> = ({ + backOptions, + badgeOptions, + border, + children, + draggableArguments, + isEditTitle, + iconAction, + isLoading, + subtitle, + subtitle2, + title, + ...rest +}) => ( + <Header border={border} {...rest}> + <EuiFlexGroup alignItems="center" justifyContent="spaceBetween"> + <FlexItem grow={false}> + {backOptions && ( + <LinkBack> + <LinkIcon href={backOptions.href} iconType="arrowLeft"> + {backOptions.text} + </LinkIcon> + </LinkBack> + )} + + {isEditTitle && iconAction ? ( + <EuiFlexGroup alignItems="center" gutterSize="m" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiFieldText + onChange={e => iconAction.onChange(e.target.value)} + value={`${title}`} + /> + </EuiFlexItem> + <EuiFlexGroup gutterSize="none" responsive={false} wrap={true}> + <EuiFlexItem grow={false}> + <EuiButton + fill + isDisabled={isLoading} + isLoading={isLoading} + onClick={iconAction.onSubmit} + > + {i18n.SUBMIT} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty onClick={() => iconAction.onClick(false)}> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexItem /> + </EuiFlexGroup> + ) : ( + <EuiTitle size="l"> + <h1 data-test-subj="header-page-title"> + {!draggableArguments ? ( + title + ) : ( + <DefaultDraggable + data-test-subj="header-page-draggable" + id={`header-page-draggable-${draggableArguments.field}-${draggableArguments.value}`} + field={draggableArguments.field} + value={`${draggableArguments.value}`} + /> + )} + {badgeOptions && ( + <> + {' '} + {badgeOptions.beta ? ( + <StyledEuiBetaBadge + label={badgeOptions.text} + tooltipContent={badgeOptions.tooltip} + tooltipPosition="bottom" + /> + ) : ( + <Badge color="hollow">{badgeOptions.text}</Badge> + )} + </> + )} + {iconAction && ( + <StyledEuiButtonIcon + aria-label={iconAction['aria-label']} + iconType={iconAction.iconType} + onClick={() => iconAction.onClick(true)} + /> + )} + </h1> + </EuiTitle> + )} + + {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} + {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} + {border && isLoading && <EuiProgress size="xs" color="accent" />} + </FlexItem> + + {children && <FlexItem data-test-subj="header-page-supplements">{children}</FlexItem>} + </EuiFlexGroup> + </Header> +); + +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 000000000000..57b2cda0b0b0 --- /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 ad6147e5aad7..c93b415e017b 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 dc8c69630161..c08b429dc462 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<LinkToPageProps>(({ match }) => ( component={RedirectToOverviewPage} path={`${match.url}/:pageName(${SiemPageName.overview})`} /> + <Route + exact + component={RedirectToCasePage} + path={`${match.url}/:pageName(${SiemPageName.case})`} + /> + <Route + exact + component={RedirectToCreatePage} + path={`${match.url}/:pageName(${SiemPageName.case})/create`} + /> + <Route + component={RedirectToCasePage} + path={`${match.url}/:pageName(${SiemPageName.case})/:detailName`} + /> <Route component={RedirectToHostsPage} exact diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx new file mode 100644 index 000000000000..39e9f6b64b1d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -0,0 +1,32 @@ +/* + * 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 { RouteComponentProps } from 'react-router-dom'; +import { RedirectWrapper } from './redirect_wrapper'; +import { SiemPageName } from '../../pages/home/types'; + +export type CaseComponentProps = RouteComponentProps<{ + detailName: string; +}>; + +export const RedirectToCasePage = ({ + match: { + params: { detailName }, + }, +}: CaseComponentProps) => ( + <RedirectWrapper + to={detailName ? `/${SiemPageName.case}/${detailName}` : `/${SiemPageName.case}`} + /> +); + +export const RedirectToCreatePage = () => <RedirectWrapper to={`/${SiemPageName.case}/create`} />; + +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 e122b3e235a9..4f74f9ff2f5d 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, +}) => ( + <EuiLink href={getCaseDetailsUrl(encodeURIComponent(detailName))}> + {children ? children : detailName} + </EuiLink> +); +export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); +CaseDetailsLink.displayName = 'CaseDetailsLink'; + +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( + <EuiLink href={getCreateCaseUrl()}>{children}</EuiLink> +)); + +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 e8d5032fd754..e25fb4374bb1 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 ac7a4a0ee52b..8eb08bd3d62f 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 5a3439d53dd8..15e58f3efd21 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 22e8f99658f8..b6ef3c8ccd4e 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 7be775ef0c0e..05329621aa97 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 fea1bc016fd4..97979e514aea 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<UrlStateType, KeyUrlState[]> = { 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 000000000000..830e00c70975 --- /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<AllCases> => { + 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<Case> => { + 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<Case> +): Promise<Partial<Case>> => { + 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 000000000000..c8d668527ae3 --- /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 000000000000..0c8b896e2b42 --- /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 000000000000..0f80b2327a30 --- /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 000000000000..8cc961c68fdf --- /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<Case>(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 000000000000..db9c07747ba0 --- /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<AllCases>(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<FilterOptions>(action.payload), + }; + default: + throw new Error(); + } +}; + +const initialData: AllCases = { + page: 0, + per_page: 0, + total: 0, + cases: [], +}; +export const useGetCases = (): [ + UseGetCasesState, + Dispatch<SetStateAction<QueryArgs>>, + Dispatch<SetStateAction<FilterOptions>> +] => { + 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 000000000000..f796ae550c9e --- /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 000000000000..5cf99701977d --- /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<NewCase>(action.payload), + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + newCase: getTypedPayload<Case>(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<SetStateAction<NewCase>>] => { + 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 000000000000..68592c17e58d --- /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<Case> | 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<UpdateByKey>(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<Case>(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 000000000000..8e6eaca1a8f0 --- /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 = <T>(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 000000000000..1206ec950dee --- /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(() => ( + <> + <WrapperPage> + <HeaderPage badgeOptions={badgeOptions} subtitle={i18n.PAGE_SUBTITLE} title={i18n.PAGE_TITLE}> + <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> + <EuiButton fill href={getCreateCaseUrl()} iconType="plusInCircle"> + {i18n.CREATE_TITLE} + </EuiButton> + </EuiFlexGroup> + </HeaderPage> + <AllCases /> + </WrapperPage> + <SpyRoute /> + </> +)); + +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 000000000000..890df91c8560 --- /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 ( + <> + <CaseView caseId={caseId} /> + <SpyRoute /> + </> + ); +}); + +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 000000000000..92cd16fd2000 --- /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<Case> | EuiTableComputedColumnType<Case>; + +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 <CaseDetailsLink detailName={theCase.case_id}>{theCase.title}</CaseDetailsLink>; + } + return getEmptyTagValue(); + }, + }, + { + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + return ( + <TruncatableText> + {tags.map((tag: string, i: number) => ( + <EuiBadge color="hollow" key={`${tag}-${i}`}> + {tag} + </EuiBadge> + ))} + </TruncatableText> + ); + } + return getEmptyTagValue(); + }, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.CREATED_AT, + sortable: true, + render: (createdAt: Case['created_at']) => { + if (createdAt != null) { + return <FormattedRelativePreferenceDate value={createdAt} />; + } + 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 <FormattedRelativePreferenceDate value={updatedAt} />; + } + 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 000000000000..b1dd39c95e19 --- /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<FilterOptions>) => { + 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<Case> = { + sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, + }; + + return ( + <Panel loading={isLoading}> + <HeaderSection split title={i18n.ALL_CASES}> + <CasesTableFilters + onFilterChanged={onFilterChangedCallback} + initial={{ search: filterOptions.search, tags: filterOptions.tags }} + /> + </HeaderSection> + {isLoading && isEmpty(data.cases) && ( + <EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} /> + )} + {!isLoading && !isEmpty(data.cases) && ( + <> + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText>{i18n.SHOWING_CASES(data.total ?? 0)}</UtilityBarText> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + <EuiBasicTable + columns={memoizedGetCasesColumns} + itemId="id" + items={data.cases} + noItemsMessage={ + <EuiEmptyPrompt + title={<h3>{i18n.NO_CASES}</h3>} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + <EuiButton fill size="s" href={getCreateCaseUrl()} iconType="plusInCircle"> + {i18n.ADD_NEW_CASE} + </EuiButton> + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + sorting={sorting} + /> + </> + )} + </Panel> + ); +}); + +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 000000000000..e59362378804 --- /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<FilterOptions>) => 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 ( + <EuiFlexGroup gutterSize="m" justifyContent="flexEnd"> + <EuiFlexItem grow={true}> + <EuiFieldSearch + aria-label={i18n.SEARCH_CASES} + fullWidth + incremental={false} + placeholder={i18n.SEARCH_PLACEHOLDER} + onSearch={handleOnSearch} + /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFilterGroup> + <TagsFilterPopover + isLoading={isLoading} + onSelectedTagsChanged={handleSelectedTags} + selectedTags={selectedTags} + tags={data} + /> + </EuiFilterGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +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 000000000000..ab8e22ebcf1b --- /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 000000000000..4f43a6edeeac --- /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<CasesProps>(({ 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: ( + <EuiFlexGroup alignItems="baseline" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <p> + <strong>{`${data.created_by.username}`}</strong> + {` ${i18n.ADDED_DESCRIPTION} `}{' '} + <FormattedRelativePreferenceDate value={data.created_at} /> + {/* STEPH FIX come back and add label `on` */} + </p> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <PropertyActions propertyActions={propertyActions} /> + </EuiFlexItem> + </EuiFlexGroup> + ), + children: isEditDescription ? ( + <> + <DescriptionMarkdown + descriptionInputHeight={200} + initialDescription={data.description} + isLoading={isLoading} + onChange={updatedDescription => setDescription(updatedDescription)} + /> + + <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> + <EuiFlexItem grow={false}> + <EuiButton + fill + isDisabled={isLoading} + isLoading={isLoading} + onClick={() => onUpdateField('description', description)} + > + {i18n.SUBMIT} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty onClick={() => setIsEditDescription(false)}> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </> + ) : ( + <Markdown raw={data.description} /> + ), + }, + ]; + return ( + <> + <MyWrapper> + <HeaderPage + backOptions={{ + href: getCaseUrl(), + text: i18n.BACK_TO_ALL, + }} + iconAction={{ + 'aria-label': title, + iconType: 'pencil', + onChange: newTitle => setTitle(newTitle), + onSubmit: () => onUpdateField('title', title), + onClick: isEdit => setIsEditTitle(isEdit), + }} + isEditTitle={isEditTitle} + title={title} + > + <EuiFlexGroup gutterSize="l" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <MyDescriptionList compressed> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle> + <EuiDescriptionListDescription> + <EuiBadge color={isCaseOpen ? 'secondary' : 'danger'}>{data.state}</EuiBadge> + </EuiDescriptionListDescription> + </EuiFlexItem> + <EuiFlexItem> + <EuiDescriptionListTitle>{i18n.CASE_OPENED}</EuiDescriptionListTitle> + <EuiDescriptionListDescription> + <FormattedRelativePreferenceDate value={data.created_at} /> + </EuiDescriptionListDescription> + </EuiFlexItem> + </EuiFlexGroup> + </MyDescriptionList> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup gutterSize="l" alignItems="center"> + <EuiFlexItem> + <EuiButtonToggle + label={isCaseOpen ? 'Close case' : 'Reopen case'} + iconType={isCaseOpen ? 'checkInCircleFilled' : 'magnet'} + onChange={onSetIsCaseOpen} + isSelected={isCaseOpen} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <PropertyActions propertyActions={propertyActions} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </HeaderPage> + </MyWrapper> + <BackgroundWrapper> + <MyWrapper> + <EuiFlexGroup> + <EuiFlexItem grow={6}> + <UserActionTree userActions={userActions} /> + </EuiFlexItem> + <EuiFlexItem grow={2}> + <UserList headline={i18n.REPORTER} users={[data.created_by]} /> + <TagList + tags={tags} + iconAction={{ + 'aria-label': title, + iconType: 'pencil', + onSubmit: newTags => onUpdateField('tags', newTags), + onClick: isEdit => setIsEditTags(isEdit), + }} + isEditTags={isEditTags} + /> + </EuiFlexItem> + </EuiFlexGroup> + </MyWrapper> + </BackgroundWrapper> + </> + ); +}); + +export const CaseView = React.memo(({ caseId }: Props) => { + const [{ data, isLoading, isError }] = useGetCase(caseId); + if (isError) { + return null; + } + if (isLoading) { + return ( + <EuiFlexGroup justifyContent="center" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiLoadingSpinner size="xl" /> + </EuiFlexItem> + </EuiFlexGroup> + ); + } + + return <Cases caseId={caseId} initialData={data} isLoading={isLoading} />; +}); + +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 000000000000..f45c52533d2e --- /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 000000000000..7bc43e23a72c --- /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 000000000000..9fd1525003b0 --- /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 <Redirect to={`/${SiemPageName.case}/${newCase.case_id}`} />; + } + return ( + <EuiPanel> + {isLoading && <MySpinner size="xl" />} + <Form form={form}> + <CommonUseField + path="title" + componentProps={{ + idAria: 'caseTitle', + 'data-test-subj': 'caseTitle', + euiFieldProps: { + fullWidth: false, + }, + isDisabled: isLoading, + }} + /> + <DescriptionMarkdown + descriptionInputHeight={200} + formHook={true} + initialDescription={data.description} + isLoading={isLoading} + onChange={description => setFormData({ ...data, description })} + /> + <TagContainer> + <CommonUseField + path="tags" + componentProps={{ + idAria: 'caseTags', + 'data-test-subj': 'caseTags', + euiFieldProps: { + fullWidth: true, + placeholder: '', + }, + isDisabled: isLoading, + }} + /> + </TagContainer> + </Form> + <> + <EuiHorizontalRule margin="m" /> + <EuiFlexGroup + alignItems="center" + justifyContent="flexEnd" + gutterSize="xs" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiButton fill isDisabled={isLoading} isLoading={isLoading} onClick={onSubmit}> + {i18n.SUBMIT} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </> + </EuiPanel> + ); +}); + +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 000000000000..b86198e09cea --- /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 = ( + <EuiText color="subdued" size="xs"> + {i18n.OPTIONAL} + </EuiText> +); 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 000000000000..1b5df72a6671 --- /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 000000000000..44062a5a1d58 --- /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 ? ( + <CommonUseField + path="description" + onChange={e => { + setDescription(e as string); + onChange(e as string); + }} + componentProps={{ + idAria: 'caseDescription', + 'data-test-subj': 'caseDescription', + isDisabled: isLoading, + spellcheck: false, + }} + /> + ) : ( + <TextArea + onChange={e => { + setDescription(e.target.value); + onChange(e.target.value); + }} + fullWidth={true} + height={descriptionInputHeight} + aria-label={i18n.DESCRIPTION} + disabled={isLoading} + spellCheck={false} + value={description} + /> + ), + }, + { + id: 'preview', + name: i18n.PREVIEW, + content: ( + <MarkdownContainer + data-test-subj="markdown-container" + height={descriptionInputHeight} + paddingSize="s" + > + <Markdown raw={description} /> + </MarkdownContainer> + ), + }, + ]; + return ( + <DescriptionContainer> + <DescriptionMarkdownTabs + data-test-subj="new-description-tabs" + tabs={tabs} + initialSelectedTab={tabs[0]} + /> + <EuiFlexItem grow={true}> + <MarkdownHint show={description.trim().length > 0} /> + </EuiFlexItem> + </DescriptionContainer> + ); +}); + +DescriptionMarkdown.displayName = 'DescriptionMarkdown'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/constants.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/constants.ts new file mode 100644 index 000000000000..14e4b46eb83f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/constants.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 SET_STATE = 'SET_STATE'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx new file mode 100644 index 000000000000..7fe5b6f5f879 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx @@ -0,0 +1,84 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPopover, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui'; + +export interface PropertyActionButtonProps { + onClick: () => void; + iconType: string; + label: string; +} + +const PropertyActionButton = React.memo<PropertyActionButtonProps>( + ({ onClick, iconType, label }) => ( + <EuiButtonEmpty + aria-label={label} + color="text" + iconSide="left" + iconType={iconType} + onClick={onClick} + > + {label} + </EuiButtonEmpty> + ) +); + +PropertyActionButton.displayName = 'PropertyActionButton'; + +export interface PropertyActionsProps { + propertyActions: PropertyActionButtonProps[]; +} + +export const PropertyActions = React.memo<PropertyActionsProps>(({ propertyActions }) => { + const [showActions, setShowActions] = useState(false); + + const onButtonClick = useCallback(() => { + setShowActions(!showActions); + }, [showActions]); + + const onClosePopover = useCallback((cb?: () => void) => { + setShowActions(false); + if (cb) { + cb(); + } + }, []); + + return ( + <EuiFlexGroup alignItems="flexStart" data-test-subj="properties-right" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiPopover + anchorPosition="downRight" + button={ + <EuiButtonIcon + data-test-subj="ellipses" + aria-label="Actions" + iconType="boxesHorizontal" + onClick={onButtonClick} + /> + } + id="settingsPopover" + isOpen={showActions} + closePopover={onClosePopover} + > + <EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="none"> + {propertyActions.map((action, key) => ( + <EuiFlexItem grow={false} key={`${action.label}${key}`}> + <PropertyActionButton + iconType={action.iconType} + label={action.label} + onClick={() => onClosePopover(action.onClick)} + /> + </EuiFlexItem> + ))} + </EuiFlexGroup> + </EuiPopover> + </EuiFlexItem> + </EuiFlexGroup> + ); +}); + +PropertyActions.displayName = 'PropertyActions'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx new file mode 100644 index 000000000000..6634672cb6a7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx @@ -0,0 +1,125 @@ +/* + * 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 { + EuiText, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import * as i18n from '../../translations'; +import { Form, useForm } from '../../../shared_imports'; +import { schema } from './schema'; +import { CommonUseField } from '../create'; + +interface IconAction { + 'aria-label': string; + iconType: string; + onClick: (b: boolean) => void; + onSubmit: (a: string[]) => void; +} + +interface TagListProps { + tags: string[]; + iconAction?: IconAction; + isEditTags?: boolean; +} + +const MyFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeM}; + p { + font-size: ${theme.eui.euiSizeM}; + } + `} +`; + +export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProps) => { + const { form } = useForm({ + defaultValue: { tags }, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid && iconAction) { + iconAction.onSubmit(newData.tags); + iconAction.onClick(false); + } + }, [form]); + + const onActionClick = useCallback( + (cb: (b: boolean) => void, onClickBool: boolean) => cb(onClickBool), + [iconAction] + ); + return ( + <EuiText> + <EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <h4>{i18n.TAGS}</h4> + </EuiFlexItem> + {iconAction && ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={iconAction['aria-label']} + iconType={iconAction.iconType} + onClick={() => onActionClick(iconAction.onClick, true)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiHorizontalRule margin="xs" /> + <MyFlexGroup gutterSize="xs"> + {tags.length === 0 && !isEditTags && <p>{i18n.NO_TAGS}</p>} + {tags.length > 0 && + !isEditTags && + tags.map((tag, key) => ( + <EuiFlexItem grow={false} key={`${tag}${key}`}> + <EuiBadge color="hollow">{tag}</EuiBadge> + </EuiFlexItem> + ))} + {isEditTags && iconAction && ( + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <Form form={form}> + <CommonUseField + path="tags" + componentProps={{ + idAria: 'caseTags', + 'data-test-subj': 'caseTags', + euiFieldProps: { + fullWidth: true, + placeholder: '', + }, + }} + /> + </Form> + </EuiFlexItem> + <EuiFlexItem> + <EuiButton fill onClick={onSubmit}> + {i18n.SUBMIT} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty onClick={() => onActionClick(iconAction.onClick, false)}> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + )} + </MyFlexGroup> + </EuiText> + ); +}); + +TagList.displayName = 'TagList'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx new file mode 100644 index 000000000000..dfc9c61cd5f0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx @@ -0,0 +1,11 @@ +/* + * 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 { FormSchema } from '../../../shared_imports'; +import { schema as createSchema } from '../create/schema'; + +export const schema: FormSchema = { + tags: createSchema.tags, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx new file mode 100644 index 000000000000..8df98a4cef0e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -0,0 +1,82 @@ +/* + * 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, { ReactNode } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAvatar, EuiPanel, EuiText } from '@elastic/eui'; +import styled, { css } from 'styled-components'; + +export interface UserActionItem { + avatarName: string; + children?: ReactNode; + title: ReactNode; +} + +export interface UserActionTreeProps { + userActions: UserActionItem[]; +} + +const UserAction = styled(EuiFlexGroup)` + ${({ theme }) => css` + & { + background-image: linear-gradient( + to right, + transparent 0, + transparent 15px, + ${theme.eui.euiBorderColor} 15px, + ${theme.eui.euiBorderColor} 17px, + transparent 17px, + transparent 100% + ); + background-repeat: no-repeat; + background-position: left ${theme.eui.euiSizeXXL}; + margin-bottom: ${theme.eui.euiSizeS}; + } + .userAction__panel { + margin-bottom: ${theme.eui.euiSize}; + } + .userAction__circle { + flex-shrink: 0; + margin-right: ${theme.eui.euiSize}; + vertical-align: top; + } + .userAction__title { + padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + background: ${theme.eui.euiColorLightestShade}; + border-bottom: ${theme.eui.euiBorderThin}; + border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; + } + .userAction__content { + padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; + } + .euiText--small * { + margin-bottom: 0; + } + `} +`; + +const renderUserActions = (userActions: UserActionItem[]) => { + return userActions.map(({ avatarName, children, title }, key) => ( + <UserAction key={key} gutterSize={'none'}> + <EuiFlexItem grow={false}> + <EuiAvatar className="userAction__circle" name={avatarName} /> + </EuiFlexItem> + <EuiFlexItem> + <EuiPanel className="userAction__panel" paddingSize="none"> + <EuiText size="s" className="userAction__title"> + {title} + </EuiText> + {children && <div className="userAction__content">{children}</div>} + </EuiPanel> + </EuiFlexItem> + </UserAction> + )); +}; + +export const UserActionTree = React.memo(({ userActions }: UserActionTreeProps) => ( + <div>{renderUserActions(userActions)}</div> +)); + +UserActionTree.displayName = 'UserActionTree'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx new file mode 100644 index 000000000000..b80ee58f8abb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -0,0 +1,72 @@ +/* + * 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 { + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import { ElasticUser } from '../../../../containers/case/types'; + +interface UserListProps { + headline: string; + users: ElasticUser[]; +} + +const MyAvatar = styled(EuiAvatar)` + top: -4px; +`; + +const MyFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeM}; + `} +`; + +const renderUsers = (users: ElasticUser[]) => { + return users.map(({ username }, key) => ( + <MyFlexGroup key={key} justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiFlexGroup gutterSize="xs"> + <EuiFlexItem> + <MyAvatar name={username} /> + </EuiFlexItem> + <EuiFlexItem> + <p> + <strong> + <small>{username}</small> + </strong> + </p> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + onClick={() => window.alert('Email clicked')} + iconType="email" + aria-label="email" + /> + </EuiFlexItem> + </MyFlexGroup> + )); +}; + +export const UserList = React.memo(({ headline, users }: UserListProps) => { + return ( + <EuiText> + <h4>{headline}</h4> + <EuiHorizontalRule margin="xs" /> + {renderUsers(users)} + </EuiText> + ); +}); + +UserList.displayName = 'UserList'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx new file mode 100644 index 000000000000..9bc356517cc6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx @@ -0,0 +1,35 @@ +/* + * 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 { WrapperPage } from '../../components/wrapper_page'; +import { Create } from './components/create'; +import { SpyRoute } from '../../utils/route/spy_routes'; +import { HeaderPage } from '../../components/header_page'; +import * as i18n from './translations'; +import { getCaseUrl } from '../../components/link_to'; + +const backOptions = { + href: getCaseUrl(), + text: i18n.BACK_TO_ALL, +}; +const badgeOptions = { + beta: true, + text: i18n.PAGE_BADGE_LABEL, + tooltip: i18n.PAGE_BADGE_TOOLTIP, +}; +export const CreateCasePage = React.memo(() => ( + <> + <WrapperPage> + <HeaderPage backOptions={backOptions} badgeOptions={badgeOptions} title={i18n.CREATE_TITLE} /> + <Create /> + </WrapperPage> + <SpyRoute /> + </> +)); + +CreateCasePage.displayName = 'CreateCasePage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx new file mode 100644 index 000000000000..9bd91b1c6d62 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -0,0 +1,33 @@ +/* + * 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 { Route, Switch } from 'react-router-dom'; +import { SiemPageName } from '../home/types'; +import { CaseDetailsPage } from './case_details'; +import { CasesPage } from './case'; +import { CreateCasePage } from './create_case'; + +const casesPagePath = `/:pageName(${SiemPageName.case})`; +const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const createCasePagePath = `${casesPagePath}/create`; + +const CaseContainerComponent: React.FC = () => ( + <Switch> + <Route strict exact path={casesPagePath}> + <CasesPage /> + </Route> + <Route strict exact path={createCasePagePath}> + <CreateCasePage /> + </Route> + <Route strict path={caseDetailsPagePath}> + <CaseDetailsPage /> + </Route> + </Switch> +); + +export const Case = React.memo(CaseContainerComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts new file mode 100644 index 000000000000..4e878ba58411 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -0,0 +1,104 @@ +/* + * 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 BACK_TO_ALL = i18n.translate('xpack.siem.case.caseView.backLabel', { + defaultMessage: 'Back to cases', +}); + +export const CANCEL = i18n.translate('xpack.siem.case.caseView.cancel', { + defaultMessage: 'Cancel', +}); + +export const CASE_TITLE = i18n.translate('xpack.siem.case.caseView.caseTitle', { + defaultMessage: 'Case Title', +}); + +export const CREATED_AT = i18n.translate('xpack.siem.case.caseView.createdAt', { + defaultMessage: 'Created at', +}); + +export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { + defaultMessage: 'Reporter', +}); + +export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { + defaultMessage: 'Create', +}); + +export const CREATE_TITLE = i18n.translate('xpack.siem.case.caseView.create', { + defaultMessage: 'Create new case', +}); + +export const DESCRIPTION = i18n.translate('xpack.siem.case.caseView.description', { + defaultMessage: 'Description', +}); + +export const DESCRIPTION_REQUIRED = i18n.translate( + 'xpack.siem.case.createCase.descriptionFieldRequiredError', + { + defaultMessage: 'A description is required.', + } +); + +export const EDIT = i18n.translate('xpack.siem.case.caseView.edit', { + defaultMessage: 'Edit', +}); + +export const OPTIONAL = i18n.translate('xpack.siem.case.caseView.optional', { + defaultMessage: 'Optional', +}); + +export const LAST_UPDATED = i18n.translate('xpack.siem.case.caseView.updatedAt', { + defaultMessage: 'Last updated', +}); + +export const PAGE_BADGE_LABEL = i18n.translate('xpack.siem.case.caseView.pageBadgeLabel', { + defaultMessage: 'Beta', +}); + +export const PAGE_BADGE_TOOLTIP = i18n.translate('xpack.siem.case.caseView.pageBadgeTooltip', { + defaultMessage: + 'Case Workflow is still in beta. Please help us improve by reporting issues or bugs in the Kibana repo.', +}); + +export const PAGE_SUBTITLE = i18n.translate('xpack.siem.case.caseView.pageSubtitle', { + defaultMessage: 'Case Workflow Management within the Elastic SIEM', +}); + +export const PAGE_TITLE = i18n.translate('xpack.siem.case.pageTitle', { + defaultMessage: 'Case Workflows', +}); + +export const PREVIEW = i18n.translate('xpack.siem.case.caseView.preview', { + defaultMessage: 'Preview', +}); + +export const STATE = i18n.translate('xpack.siem.case.caseView.state', { + defaultMessage: 'State', +}); + +export const SUBMIT = i18n.translate('xpack.siem.case.caseView.submit', { + defaultMessage: 'Submit', +}); + +export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { + defaultMessage: 'Tags', +}); + +export const TAGS_HELP = i18n.translate('xpack.siem.case.createCase.fieldTagsHelpText', { + defaultMessage: + 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', +}); + +export const NO_TAGS = i18n.translate('xpack.siem.case.caseView.noTags', { + defaultMessage: 'No tags are currently assigned to this case.', +}); + +export const TITLE_REQUIRED = i18n.translate('xpack.siem.case.createCase.titleFieldRequiredError', { + defaultMessage: 'A title is required.', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts new file mode 100644 index 000000000000..bd6cb5da5eb0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -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 { Breadcrumb } from 'ui/chrome'; +import { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl } from '../../components/link_to'; +import { RouteSpyState } from '../../utils/route/types'; +import * as i18n from './translations'; + +export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { + let breadcrumb = [ + { + text: i18n.PAGE_TITLE, + href: getCaseUrl(), + }, + ]; + if (params.detailName === 'create') { + breadcrumb = [ + ...breadcrumb, + { + text: i18n.CREATE_BC_TITLE, + href: getCreateCaseUrl(), + }, + ]; + } else if (params.detailName != null) { + breadcrumb = [ + ...breadcrumb, + { + text: params.detailName, + href: getCaseDetailsUrl(params.detailName), + }, + ]; + } + return breadcrumb; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index c54a2e8d4984..fa4f6a874ca5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; +import { isEqual } from 'lodash/fp'; import * as i18n from '../../translations'; import { FilterOptions } from '../../../../../containers/detection_engine/rules'; @@ -59,6 +60,15 @@ const RulesTableFiltersComponent = ({ setShowElasticRules(false); }, [setShowElasticRules, showCustomRules, setShowCustomRules]); + const handleSelectedTags = useCallback( + newTags => { + if (!isEqual(newTags, selectedTags)) { + setSelectedTags(newTags); + } + }, + [selectedTags] + ); + return ( <EuiFlexGroup gutterSize="m" justifyContent="flexEnd"> <EuiFlexItem grow={true}> @@ -74,9 +84,10 @@ const RulesTableFiltersComponent = ({ <EuiFlexItem grow={false}> <EuiFilterGroup> <TagsFilterPopover - tags={tags} - onSelectedTagsChanged={setSelectedTags} isLoading={isLoadingTags} + onSelectedTagsChanged={handleSelectedTags} + selectedTags={selectedTags} + tags={tags} /> </EuiFilterGroup> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx index b9d2c97f063b..44149a072f5c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import React, { Dispatch, SetStateAction, useState } from 'react'; import { EuiFilterButton, EuiFilterSelectItem, @@ -19,9 +19,10 @@ import * as i18n from '../../translations'; import { toggleSelectedGroup } from '../../../../../components/ml_popover/jobs_table/filters/toggle_selected_group'; interface TagsFilterPopoverProps { + selectedTags: string[]; tags: string[]; onSelectedTagsChanged: Dispatch<SetStateAction<string[]>>; - isLoading: boolean; + isLoading: boolean; // TO DO reimplement? } const ScrollableDiv = styled.div` @@ -37,14 +38,10 @@ const ScrollableDiv = styled.div` */ export const TagsFilterPopoverComponent = ({ tags, + selectedTags, onSelectedTagsChanged, }: TagsFilterPopoverProps) => { const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false); - const [selectedTags, setSelectedTags] = useState<string[]>([]); - - useEffect(() => { - onSelectedTagsChanged(selectedTags); - }, [selectedTags.sort().join()]); return ( <EuiPopover @@ -70,7 +67,7 @@ export const TagsFilterPopoverComponent = ({ <EuiFilterSelectItem checked={selectedTags.includes(tag) ? 'on' : undefined} key={`${index}-${tag}`} - onClick={() => toggleSelectedGroup(tag, selectedTags, setSelectedTags)} + onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)} > {`${tag}`} </EuiFilterSelectItem> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index 0c75da7d8a63..cc5e9b38eb2f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -18,7 +18,7 @@ import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 're import styled from 'styled-components'; import * as RuleI18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; interface AddItemProps { addText: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 09f4c13acbf6..1cc7bba5558d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -19,7 +19,7 @@ import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/searc import { useKibana } from '../../../../../lib/kibana'; import { IMitreEnterpriseAttack } from '../../types'; import { FieldValueTimeline } from '../pick_timeline'; -import { FormSchema } from '../shared_imports'; +import { FormSchema } from '../../../../shared_imports'; import { ListItems } from './types'; import { buildQueryBarDescription, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index d85be053065f..b49126c8c0fe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; import * as Rulei18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; import { threatDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; import { MyAddItemButton } from '../add_item_form'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx index f467d0ebede4..56cb02c9ec81 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -8,7 +8,7 @@ import { EuiFormRow } from '@elastic/eui'; import React, { useCallback, useEffect, useState } from 'react'; import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; export interface FieldValueTimeline { id: string | null; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index 7f55d76c6d6b..88795f9195e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -29,7 +29,7 @@ import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; import * as i18n from './translations'; export interface FieldValueQueryBar { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 3bde2087f26b..ffb6c4eda324 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -16,7 +16,7 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 9c351e66c2f0..45da7d081333 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -23,7 +23,14 @@ import * as RuleI18n from '../../translations'; import { AddItem } from '../add_item_form'; import { StepRuleDescription } from '../description_step'; import { AddMitreThreat } from '../mitre'; -import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; +import { + Field, + Form, + FormDataProvider, + getUseField, + UseField, + useForm, +} from '../../../../shared_imports'; import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 22033dcf6b0f..27887bcbbe60 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { FormSchema, ValidationFunc, ERROR_CODE, -} from '../shared_imports'; +} from '../../../../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 5409a5f161bb..920a9f2dfe56 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -25,7 +25,14 @@ import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; import { StepContentWrapper } from '../step_content_wrapper'; -import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; +import { + Field, + Form, + FormDataProvider, + getUseField, + UseField, + useForm, +} from '../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 079ec0dab4c5..bb178d719706 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -17,7 +17,7 @@ import { fieldValidators, FormSchema, ValidationFunc, -} from '../shared_imports'; +} from '../../../../shared_imports'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 532df628a83a..cfbb0a622c72 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -12,7 +12,7 @@ import { setFieldValue } from '../../helpers'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; -import { Form, UseField, useForm } from '../shared_imports'; +import { Form, UseField, useForm } from '../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index a951c1fab7cc..9932e4f6ef43 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; -import { FormSchema } from '../shared_imports'; +import { FormSchema } from '../../../../shared_imports'; export const schema: FormSchema = { interval: { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 3adc22329ac4..c985045b1897 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -17,7 +17,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; -import { FormData, FormHook } from '../components/shared_imports'; +import { FormData, FormHook } from '../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 99fcff6b8d2f..0fac4641e54a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -26,7 +26,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../components/shared_imports'; +import { FormHook, FormData } from '../../../shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index cfff71851b2e..3fab456d856c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -11,7 +11,7 @@ import { useLocation } from 'react-router-dom'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { FormData, FormHook, FormSchema } from './components/shared_imports'; +import { FormData, FormHook, FormSchema } from '../../shared_imports'; import { AboutStepRule, DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule } from './types'; interface GetStepsData { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index fc2e3fba2444..55eb45fb5ed9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -7,7 +7,7 @@ import { Filter } from '../../../../../../../../src/plugins/data/common'; import { Rule } from '../../../containers/detection_engine/rules'; import { FieldValueQueryBar } from './components/query_bar'; -import { FormData, FormHook } from './components/shared_imports'; +import { FormData, FormHook } from '../../shared_imports'; import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index c0e959c5e97f..42d333f4f893 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -10,6 +10,7 @@ import { getNetworkUrl, getTimelinesUrl, getHostsUrl, + getCaseUrl, } from '../../components/link_to'; import * as i18n from './translations'; import { SiemPageName, SiemNavTab } from './types'; @@ -50,4 +51,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: 'timeline', }, + [SiemPageName.case]: { + id: SiemPageName.case, + name: i18n.CASE, + href: getCaseUrl(), + disabled: true, + urlKey: 'case', + }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/home/index.tsx b/x-pack/legacy/plugins/siem/public/pages/home/index.tsx index fd7c536d5408..1dce26b7c5d3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/index.tsx @@ -26,6 +26,7 @@ import { DetectionEngineContainer } from '../detection_engine'; import { HostsContainer } from '../hosts'; import { NetworkContainer } from '../network'; import { Overview } from '../overview'; +import { Case } from '../case'; import { Timelines } from '../timelines'; import { navTabs } from './home_navigations'; import { SiemPageName } from './types'; @@ -42,6 +43,11 @@ const WrappedByAutoSizer = styled.div` `; WrappedByAutoSizer.displayName = 'WrappedByAutoSizer'; +const Main = styled.main` + height: 100%; +`; +Main.displayName = 'Main'; + const usersViewing = ['elastic']; // TODO: get the users viewing this timeline from Elasticsearch (persistance) /** the global Kibana navigation at the top of every page */ @@ -61,7 +67,7 @@ export const HomePage: React.FC = () => ( <WrappedByAutoSizer data-test-subj="wrapped-by-auto-sizer" ref={measureRef}> <HeaderGlobal /> - <main data-test-subj="pageContainer"> + <Main data-test-subj="pageContainer"> <WithSource sourceId="default"> {({ browserFields, indexPattern, indicesExist }) => ( <DragDropContextWrapper browserFields={browserFields}> @@ -129,12 +135,15 @@ export const HomePage: React.FC = () => ( <MlNetworkConditionalContainer location={location} url={match.url} /> )} /> + <Route path={`/:pageName(${SiemPageName.case})`}> + <Case /> + </Route> <Route render={() => <NotFoundPage />} /> </Switch> </DragDropContextWrapper> )} </WithSource> - </main> + </Main> <HelpMenu /> diff --git a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts index 80800a3bd419..581c81d9f98a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts @@ -25,3 +25,7 @@ export const DETECTION_ENGINE = i18n.translate('xpack.siem.navigation.detectionE export const TIMELINES = i18n.translate('xpack.siem.navigation.timelines', { defaultMessage: 'Timelines', }); + +export const CASE = i18n.translate('xpack.siem.navigation.case', { + defaultMessage: 'Case', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/home/types.ts b/x-pack/legacy/plugins/siem/public/pages/home/types.ts index 678de6dbcc12..6445ac91d9e1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/home/types.ts @@ -12,6 +12,7 @@ export enum SiemPageName { network = 'network', detections = 'detections', timelines = 'timelines', + case = 'case', } export type SiemNavTabKey = @@ -19,6 +20,7 @@ export type SiemNavTabKey = | SiemPageName.hosts | SiemPageName.network | SiemPageName.detections - | SiemPageName.timelines; + | SiemPageName.timelines + | SiemPageName.case; export type SiemNavTab = Record<SiemNavTabKey, NavTab>; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/shared_imports.ts b/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts similarity index 50% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/shared_imports.ts rename to x-pack/legacy/plugins/siem/public/pages/shared_imports.ts index 494da24be706..a41f121b3692 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts @@ -17,7 +17,7 @@ export { UseField, useForm, ValidationFunc, -} from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/components'; -export { fieldValidators } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/helpers'; -export { ERROR_CODE } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; +} from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { Field } from '../../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/legacy/plugins/siem/public/store/model.ts b/x-pack/legacy/plugins/siem/public/store/model.ts index 6f04f22866be..9e9e663a59fe 100644 --- a/x-pack/legacy/plugins/siem/public/store/model.ts +++ b/x-pack/legacy/plugins/siem/public/store/model.ts @@ -5,9 +5,9 @@ */ export { appModel } from './app'; -export { inputsModel } from './inputs'; -export { hostsModel } from './hosts'; export { dragAndDropModel } from './drag_and_drop'; +export { hostsModel } from './hosts'; +export { inputsModel } from './inputs'; export { networkModel } from './network'; export type KueryFilterQueryKind = 'kuery' | 'lucene'; diff --git a/x-pack/legacy/plugins/siem/public/utils/use_global_loading.ts b/x-pack/legacy/plugins/siem/public/utils/use_global_loading.ts deleted file mode 100644 index 37abe2f28d31..000000000000 --- a/x-pack/legacy/plugins/siem/public/utils/use_global_loading.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useState, useEffect } from 'react'; - -export const useGlobalLoading = () => { - const [isInitializing, setIsInitializing] = useState(true); - useEffect(() => { - if (isInitializing) { - setIsInitializing(false); - } - }); - return isInitializing; -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts similarity index 77% rename from x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts rename to x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts index bd73805600a3..80cdb9e979a6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts +++ b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts @@ -5,10 +5,7 @@ */ /* eslint-disable @typescript-eslint/no-empty-interface */ /* eslint-disable @typescript-eslint/camelcase */ -import { - NewCaseFormatted, - NewCommentFormatted, -} from '../../../../../../../x-pack/plugins/case/server'; +import { CaseAttributes, CommentAttributes } from '../../../../../../../x-pack/plugins/case/server'; import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; // Temporary file to write mappings for case @@ -19,20 +16,10 @@ export const caseSavedObjectType = 'case-workflow'; export const caseCommentSavedObjectType = 'case-workflow-comment'; export const caseSavedObjectMappings: { - [caseSavedObjectType]: ElasticsearchMappingOf<NewCaseFormatted>; + [caseSavedObjectType]: ElasticsearchMappingOf<CaseAttributes>; } = { [caseSavedObjectType]: { properties: { - assignees: { - properties: { - username: { - type: 'keyword', - }, - full_name: { - type: 'keyword', - }, - }, - }, created_at: { type: 'date', }, @@ -58,15 +45,15 @@ export const caseSavedObjectMappings: { tags: { type: 'keyword', }, - case_type: { - type: 'keyword', + updated_at: { + type: 'date', }, }, }, }; export const caseCommentSavedObjectMappings: { - [caseCommentSavedObjectType]: ElasticsearchMappingOf<NewCommentFormatted>; + [caseCommentSavedObjectType]: ElasticsearchMappingOf<CommentAttributes>; } = { [caseCommentSavedObjectType]: { properties: { @@ -86,6 +73,9 @@ export const caseCommentSavedObjectMappings: { }, }, }, + updated_at: { + type: 'date', + }, }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/saved_objects.ts b/x-pack/legacy/plugins/siem/server/saved_objects.ts index 8b9a1891c8a5..58da333c7bc9 100644 --- a/x-pack/legacy/plugins/siem/server/saved_objects.ts +++ b/x-pack/legacy/plugins/siem/server/saved_objects.ts @@ -16,6 +16,10 @@ import { ruleStatusSavedObjectMappings, ruleStatusSavedObjectType, } from './lib/detection_engine/rules/saved_object_mappings'; +import { + caseSavedObjectMappings, + caseCommentSavedObjectMappings, +} from './lib/case/saved_object_mappings'; export { noteSavedObjectType, @@ -27,5 +31,8 @@ export const savedObjectMappings = { ...timelineSavedObjectMappings, ...noteSavedObjectMappings, ...pinnedEventSavedObjectMappings, + // TODO: Remove once while Saved Object Mappings API is programmed for the NP See: https://github.com/elastic/kibana/issues/50309 + ...caseSavedObjectMappings, + ...caseCommentSavedObjectMappings, ...ruleStatusSavedObjectMappings, }; diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index 3963debea979..990aef19b74f 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -7,7 +7,7 @@ import { PluginInitializerContext } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { CasePlugin } from './plugin'; -export { NewCaseFormatted, NewCommentFormatted } from './routes/api/types'; +export { CaseAttributes, CommentAttributes } from './routes/api/types'; export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 360c6de67b2a..eb9afb27a749 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -21,6 +21,8 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } return { + page: 1, + per_page: 5, total: savedObject.length, saved_objects: savedObject, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index 84889c3ac49b..ac9eddd6dd2c 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -12,7 +12,7 @@ import { RouteDeps } from '../index'; export const createRoute = async ( api: (deps: RouteDeps) => void, - method: 'get' | 'post' | 'delete', + method: 'get' | 'post' | 'delete' | 'patch', badAuth = false ) => { const httpService = httpServiceMock.createSetupContract(); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index d59f0977e699..c7f6b6fad7d1 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -9,17 +9,16 @@ export const mockCases = [ type: 'case-workflow', id: 'mock-id-1', attributes: { - created_at: 1574718888885, + created_at: '2019-11-25T21:54:48.952Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', state: 'open', tags: ['defacement'], - case_type: 'security', - assignees: [], + updated_at: '2019-11-25T21:54:48.952Z', }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -29,17 +28,16 @@ export const mockCases = [ type: 'case-workflow', id: 'mock-id-2', attributes: { - created_at: 1574721120834, + created_at: '2019-11-25T22:32:00.900Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', title: 'Damaging Data Destruction Detected', state: 'open', tags: ['Data Destruction'], - case_type: 'security', - assignees: [], + updated_at: '2019-11-25T22:32:00.900Z', }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -49,17 +47,16 @@ export const mockCases = [ type: 'case-workflow', id: 'mock-id-3', attributes: { - created_at: 1574721137881, + created_at: '2019-11-25T22:32:17.947Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', title: 'Another bad one', state: 'open', tags: ['LOLBins'], - case_type: 'security', - assignees: [], + updated_at: '2019-11-25T22:32:17.947Z', }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -82,11 +79,12 @@ export const mockCaseComments = [ id: 'mock-comment-1', attributes: { comment: 'Wow, good luck catching that bad meanie!', - created_at: 1574718900112, + created_at: '2019-11-25T21:55:00.177Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, + updated_at: '2019-11-25T21:55:00.177Z', }, references: [ { @@ -103,11 +101,12 @@ export const mockCaseComments = [ id: 'mock-comment-2', attributes: { comment: 'Well I decided to update my comment. So what? Deal with it.', - created_at: 1574718902724, + created_at: '2019-11-25T21:55:14.633Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, + updated_at: '2019-11-25T21:55:14.633Z', }, references: [ { @@ -124,11 +123,12 @@ export const mockCaseComments = [ id: 'mock-comment-3', attributes: { comment: 'Wow, good luck catching that bad meanie!', - created_at: 1574721150542, + created_at: '2019-11-25T22:32:30.608Z', created_by: { - full_name: null, + full_name: 'elastic', username: 'elastic', }, + updated_at: '2019-11-25T22:32:30.608Z', }, references: [ { diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts index 2f8a229c08f2..96c411a746d4 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts @@ -19,7 +19,7 @@ describe('GET all cases', () => { beforeAll(async () => { routeHandler = await createRoute(initGetAllCasesApi, 'get'); }); - it(`returns the case without case comments when includeComments is false`, async () => { + it(`gets all the cases`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'get', @@ -29,6 +29,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.saved_objects).toHaveLength(3); + expect(response.payload.cases).toHaveLength(3); }); }); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts index 3c5f8e52d194..60becf1228a0 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts @@ -12,15 +12,17 @@ import { mockCasesErrorTriggerData, } from '../__fixtures__'; import { initGetCaseApi } from '../get_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; +import { flattenCaseSavedObject } from '../utils'; +import { CaseAttributes } from '../types'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { routeHandler = await createRoute(initGetCaseApi, 'get'); }); - it(`returns the case without case comments when includeComments is false`, async () => { + it(`returns the case with empty case comments when includeComments is false`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/{id}', params: { @@ -37,8 +39,13 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual(mockCases.find(s => s.id === 'mock-id-1')); - expect(response.payload.comments).toBeUndefined(); + expect(response.payload).toEqual( + flattenCaseSavedObject( + (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject<CaseAttributes>, + [] + ) + ); + expect(response.payload.comments).toEqual([]); }); it(`returns an error when thrown from getCase`, async () => { const request = httpServerMock.createKibanaRequest({ @@ -76,7 +83,7 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments.saved_objects).toHaveLength(3); + expect(response.payload.comments).toHaveLength(3); }); it(`returns an error when thrown from getAllCaseComments`, async () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts index 9b6a1e435838..3add93acc641 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts @@ -11,8 +11,10 @@ import { mockCaseComments, } from '../__fixtures__'; import { initGetCommentApi } from '../get_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; +import { flattenCommentSavedObject } from '../utils'; +import { CommentAttributes } from '../types'; describe('GET comment', () => { let routeHandler: RequestHandler<any, any, any>; @@ -32,7 +34,11 @@ describe('GET comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual(mockCaseComments.find(s => s.id === 'mock-comment-1')); + expect(response.payload).toEqual( + flattenCommentSavedObject( + mockCaseComments.find(s => s.id === 'mock-comment-1') as SavedObject<CommentAttributes> + ) + ); }); it(`returns an error when getComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts index bb688dde4c58..32c7c5a015af 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts @@ -28,7 +28,6 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', state: 'open', tags: ['defacement'], - case_type: 'security', }, }); @@ -36,8 +35,8 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.id).toEqual('mock-it'); - expect(response.payload.attributes.created_by.username).toEqual('awesome'); + expect(response.payload.case_id).toEqual('mock-it'); + expect(response.payload.created_by.username).toEqual('awesome'); }); it(`Returns an error if postNewCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ @@ -48,7 +47,6 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', state: 'open', tags: ['error'], - case_type: 'security', }, }); @@ -69,7 +67,6 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', state: 'open', tags: ['defacement'], - case_type: 'security', }, }); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts index 0c059b7f15ea..653140af2a7c 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts @@ -35,8 +35,7 @@ describe('POST comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.id).toEqual('mock-comment'); - expect(response.payload.references[0].id).toEqual('mock-id-1'); + expect(response.payload.comment_id).toEqual('mock-comment'); }); it(`Returns an error if the case does not exist`, async () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts index 7ed478d2e7c0..23283d7f8a5b 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -17,12 +17,12 @@ import { httpServerMock } from 'src/core/server/mocks'; describe('UPDATE case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCaseApi, 'post'); + routeHandler = await createRoute(initUpdateCaseApi, 'patch'); }); it(`Updates a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/{id}', - method: 'post', + method: 'patch', params: { id: 'mock-id-1', }, @@ -35,13 +35,13 @@ describe('UPDATE case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.id).toEqual('mock-id-1'); - expect(response.payload.attributes.state).toEqual('closed'); + expect(typeof response.payload.updated_at).toBe('string'); + expect(response.payload.state).toEqual('closed'); }); it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/{id}', - method: 'post', + method: 'patch', params: { id: 'mock-id-does-not-exist', }, diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts index 8aa84b45b7db..5bfd121691ab 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -17,12 +17,12 @@ import { httpServerMock } from 'src/core/server/mocks'; describe('UPDATE comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCommentApi, 'post'); + routeHandler = await createRoute(initUpdateCommentApi, 'patch'); }); it(`Updates a comment`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/comment/{id}', - method: 'post', + method: 'patch', params: { id: 'mock-comment-1', }, @@ -35,13 +35,12 @@ describe('UPDATE comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.id).toEqual('mock-comment-1'); - expect(response.payload.attributes.comment).toEqual('Update my comment'); + expect(response.payload.comment).toEqual('Update my comment'); }); it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/comment/{id}', - method: 'post', + method: 'patch', params: { id: 'mock-comment-does-not-exist', }, diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts index cc4956ead1bd..b74227fa8d98 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts +++ b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { wrapError } from './utils'; +import { formatAllComments, wrapError } from './utils'; export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -24,7 +24,7 @@ export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseId: request.params.id, }); - return response.ok({ body: theComments }); + return response.ok({ body: formatAllComments(theComments) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts index 749a183dfe98..09075a32ac37 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_cases.ts +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.ts @@ -4,21 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { wrapError } from './utils'; +import { formatAllCases, wrapError } from './utils'; +import { SavedObjectsFindOptionsSchema } from './schema'; +import { AllCases } from './types'; export function initGetAllCasesApi({ caseService, router }: RouteDeps) { router.get( { path: '/api/cases', - validate: false, + validate: { + query: schema.nullable(SavedObjectsFindOptionsSchema), + }, }, async (context, request, response) => { try { - const cases = await caseService.getAllCases({ - client: context.core.savedObjects.client, + const args = request.query + ? { + client: context.core.savedObjects.client, + options: request.query, + } + : { + client: context.core.savedObjects.client, + }; + const cases = await caseService.getAllCases(args); + const body: AllCases = formatAllCases(cases); + return response.ok({ + body, }); - return response.ok({ body: cases }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/get_case.ts index 6aad22a1ebf1..2481197000be 100644 --- a/x-pack/plugins/case/server/routes/api/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/get_case.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { wrapError } from './utils'; +import { flattenCaseSavedObject, wrapError } from './utils'; export function initGetCaseApi({ caseService, router }: RouteDeps) { router.get( @@ -33,14 +33,16 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { return response.customError(wrapError(error)); } if (!includeComments) { - return response.ok({ body: theCase }); + return response.ok({ body: flattenCaseSavedObject(theCase, []) }); } try { const theComments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, caseId: request.params.id, }); - return response.ok({ body: { ...theCase, comments: theComments } }); + return response.ok({ + body: { ...flattenCaseSavedObject(theCase, theComments.saved_objects) }, + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/get_comment.ts index 6fd507d89738..d892b4cfebc3 100644 --- a/x-pack/plugins/case/server/routes/api/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/get_comment.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { wrapError } from './utils'; +import { flattenCommentSavedObject, wrapError } from './utils'; export function initGetCommentApi({ caseService, router }: RouteDeps) { router.get( @@ -24,7 +24,7 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, commentId: request.params.id, }); - return response.ok({ body: theComment }); + return response.ok({ body: flattenCommentSavedObject(theComment) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/get_tags.ts b/x-pack/plugins/case/server/routes/api/get_tags.ts new file mode 100644 index 000000000000..1d714db4c0c2 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_tags.ts @@ -0,0 +1,28 @@ +/* + * 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 { RouteDeps } from './index'; +import { wrapError } from './utils'; + +export function initGetTagsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/tags', + validate: {}, + }, + async (context, request, response) => { + let theCase; + try { + theCase = await caseService.getTags({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: theCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 11ef91d539e8..32dfd6a78d1c 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -5,17 +5,18 @@ */ import { IRouter } from 'src/core/server'; -import { initDeleteCommentApi } from './delete_comment'; +import { CaseServiceSetup } from '../../services'; import { initDeleteCaseApi } from './delete_case'; +import { initDeleteCommentApi } from './delete_comment'; import { initGetAllCaseCommentsApi } from './get_all_case_comments'; import { initGetAllCasesApi } from './get_all_cases'; import { initGetCaseApi } from './get_case'; import { initGetCommentApi } from './get_comment'; +import { initGetTagsApi } from './get_tags'; import { initPostCaseApi } from './post_case'; import { initPostCommentApi } from './post_comment'; import { initUpdateCaseApi } from './update_case'; import { initUpdateCommentApi } from './update_comment'; -import { CaseServiceSetup } from '../../services'; export interface RouteDeps { caseService: CaseServiceSetup; @@ -23,12 +24,13 @@ export interface RouteDeps { } export function initCaseApi(deps: RouteDeps) { + initDeleteCaseApi(deps); + initDeleteCommentApi(deps); initGetAllCaseCommentsApi(deps); initGetAllCasesApi(deps); initGetCaseApi(deps); initGetCommentApi(deps); - initDeleteCaseApi(deps); - initDeleteCommentApi(deps); + initGetTagsApi(deps); initPostCaseApi(deps); initPostCommentApi(deps); initUpdateCaseApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts index e5aa0a3548b4..948bf02d5b3c 100644 --- a/x-pack/plugins/case/server/routes/api/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/post_case.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { formatNewCase, wrapError } from './utils'; +import { flattenCaseSavedObject, formatNewCase, wrapError } from './utils'; import { NewCaseSchema } from './schema'; import { RouteDeps } from '.'; @@ -31,7 +31,7 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { ...createdBy, }), }); - return response.ok({ body: newCase }); + return response.ok({ body: flattenCaseSavedObject(newCase, []) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts index 3f4592f5bb11..f3f21becddfa 100644 --- a/x-pack/plugins/case/server/routes/api/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/post_comment.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { formatNewComment, wrapError } from './utils'; +import { flattenCommentSavedObject, formatNewComment, wrapError } from './utils'; import { NewCommentSchema } from './schema'; import { RouteDeps } from '.'; import { CASE_SAVED_OBJECT } from '../../constants'; @@ -53,7 +53,7 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { ], }); - return response.ok({ body: newComment }); + return response.ok({ body: flattenCommentSavedObject(newComment) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts index 4a4a0c3a11e3..962dc474254f 100644 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -7,8 +7,8 @@ import { schema } from '@kbn/config-schema'; export const UserSchema = schema.object({ - username: schema.string(), full_name: schema.maybe(schema.string()), + username: schema.string(), }); export const NewCommentSchema = schema.object({ @@ -17,28 +17,38 @@ export const NewCommentSchema = schema.object({ export const CommentSchema = schema.object({ comment: schema.string(), - created_at: schema.number(), + created_at: schema.string(), created_by: UserSchema, + updated_at: schema.string(), }); export const UpdatedCommentSchema = schema.object({ comment: schema.string(), + updated_at: schema.string(), }); export const NewCaseSchema = schema.object({ - assignees: schema.arrayOf(UserSchema, { defaultValue: [] }), description: schema.string(), - title: schema.string(), state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), tags: schema.arrayOf(schema.string(), { defaultValue: [] }), - case_type: schema.string(), + title: schema.string(), }); export const UpdatedCaseSchema = schema.object({ - assignees: schema.maybe(schema.arrayOf(UserSchema)), description: schema.maybe(schema.string()), - title: schema.maybe(schema.string()), state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), tags: schema.maybe(schema.arrayOf(schema.string())), - case_type: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), +}); + +export const SavedObjectsFindOptionsSchema = schema.object({ + defaultSearchOperator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), + fields: schema.maybe(schema.arrayOf(schema.string())), + filter: schema.maybe(schema.string()), + page: schema.maybe(schema.number()), + perPage: schema.maybe(schema.number()), + search: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.arrayOf(schema.string())), + sortField: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), }); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index d943e4e5fd7d..2d1a88bcf142 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -9,28 +9,63 @@ import { CommentSchema, NewCaseSchema, NewCommentSchema, + SavedObjectsFindOptionsSchema, UpdatedCaseSchema, UpdatedCommentSchema, UserSchema, } from './schema'; +import { SavedObjectAttributes } from '../../../../../../src/core/types'; export type NewCaseType = TypeOf<typeof NewCaseSchema>; -export type NewCommentFormatted = TypeOf<typeof CommentSchema>; +export type CommentAttributes = TypeOf<typeof CommentSchema> & SavedObjectAttributes; export type NewCommentType = TypeOf<typeof NewCommentSchema>; +export type SavedObjectsFindOptionsType = TypeOf<typeof SavedObjectsFindOptionsSchema>; export type UpdatedCaseTyped = TypeOf<typeof UpdatedCaseSchema>; export type UpdatedCommentType = TypeOf<typeof UpdatedCommentSchema>; export type UserType = TypeOf<typeof UserSchema>; -export interface NewCaseFormatted extends NewCaseType { - created_at: number; +export interface CaseAttributes extends NewCaseType, SavedObjectAttributes { + created_at: string; created_by: UserType; + updated_at: string; +} + +export type FlattenedCaseSavedObject = CaseAttributes & { + case_id: string; + comments: FlattenedCommentSavedObject[]; +}; + +export type FlattenedCasesSavedObject = Array< + CaseAttributes & { + case_id: string; + // TO DO it is partial because we need to add it the commentCount + commentCount?: number; + } +>; + +export interface AllCases { + cases: FlattenedCasesSavedObject; + page: number; + per_page: number; + total: number; +} + +export type FlattenedCommentSavedObject = CommentAttributes & { + comment_id: string; + // TO DO We might want to add the case_id where this comment is related too +}; + +export interface AllComments { + comments: FlattenedCommentSavedObject[]; + page: number; + per_page: number; + total: number; } export interface UpdatedCaseType { - assignees?: UpdatedCaseTyped['assignees']; description?: UpdatedCaseTyped['description']; - title?: UpdatedCaseTyped['title']; state?: UpdatedCaseTyped['state']; tags?: UpdatedCaseTyped['tags']; - case_type?: UpdatedCaseTyped['case_type']; + title?: UpdatedCaseTyped['title']; + updated_at: string; } diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts index 52c8cab0022d..2a814c7259e4 100644 --- a/x-pack/plugins/case/server/routes/api/update_case.ts +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -10,7 +10,7 @@ import { RouteDeps } from '.'; import { UpdatedCaseSchema } from './schema'; export function initUpdateCaseApi({ caseService, router }: RouteDeps) { - router.post( + router.patch( { path: '/api/cases/{id}', validate: { @@ -25,9 +25,12 @@ export function initUpdateCaseApi({ caseService, router }: RouteDeps) { const updatedCase = await caseService.updateCase({ client: context.core.savedObjects.client, caseId: request.params.id, - updatedAttributes: request.body, + updatedAttributes: { + ...request.body, + updated_at: new Date().toISOString(), + }, }); - return response.ok({ body: updatedCase }); + return response.ok({ body: updatedCase.attributes }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts index e1ee6029e8e4..815f44a14e2e 100644 --- a/x-pack/plugins/case/server/routes/api/update_comment.ts +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -10,7 +10,7 @@ import { NewCommentSchema } from './schema'; import { RouteDeps } from '.'; export function initUpdateCommentApi({ caseService, router }: RouteDeps) { - router.post( + router.patch( { path: '/api/cases/comment/{id}', validate: { @@ -25,9 +25,12 @@ export function initUpdateCommentApi({ caseService, router }: RouteDeps) { const updatedComment = await caseService.updateComment({ client: context.core.savedObjects.client, commentId: request.params.id, - updatedAttributes: request.body, + updatedAttributes: { + ...request.body, + updated_at: new Date().toISOString(), + }, }); - return response.ok({ body: updatedComment }); + return response.ok({ body: updatedComment.attributes }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index c6e33dbb8433..51944b04836a 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -5,21 +5,31 @@ */ import { boomify, isBoom } from 'boom'; -import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; import { + CustomHttpResponseOptions, + ResponseError, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; +import { + AllComments, + CaseAttributes, + CommentAttributes, + FlattenedCaseSavedObject, + FlattenedCommentSavedObject, + AllCases, NewCaseType, - NewCaseFormatted, NewCommentType, - NewCommentFormatted, UserType, } from './types'; export const formatNewCase = ( newCase: NewCaseType, { full_name, username }: { full_name?: string; username: string } -): NewCaseFormatted => ({ - created_at: new Date().valueOf(), +): CaseAttributes => ({ + created_at: new Date().toISOString(), created_by: { full_name, username }, + updated_at: new Date().toISOString(), ...newCase, }); @@ -32,10 +42,11 @@ export const formatNewComment = ({ newComment, full_name, username, -}: NewCommentArgs): NewCommentFormatted => ({ +}: NewCommentArgs): CommentAttributes => ({ ...newComment, - created_at: new Date().valueOf(), + created_at: new Date().toISOString(), created_by: { full_name, username }, + updated_at: new Date().toISOString(), }); export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> { @@ -46,3 +57,55 @@ export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> statusCode: boom.output.statusCode, }; } + +export const formatAllCases = (cases: SavedObjectsFindResponse<CaseAttributes>): AllCases => ({ + page: cases.page, + per_page: cases.per_page, + total: cases.total, + cases: flattenCaseSavedObjects(cases.saved_objects), +}); + +export const flattenCaseSavedObjects = ( + savedObjects: SavedObjectsFindResponse<CaseAttributes>['saved_objects'] +): FlattenedCaseSavedObject[] => + savedObjects.reduce( + (acc: FlattenedCaseSavedObject[], savedObject: SavedObject<CaseAttributes>) => { + return [...acc, flattenCaseSavedObject(savedObject, [])]; + }, + [] + ); + +export const flattenCaseSavedObject = ( + savedObject: SavedObject<CaseAttributes>, + comments: Array<SavedObject<CommentAttributes>> +): FlattenedCaseSavedObject => ({ + case_id: savedObject.id, + comments: flattenCommentSavedObjects(comments), + ...savedObject.attributes, +}); + +export const formatAllComments = ( + comments: SavedObjectsFindResponse<CommentAttributes> +): AllComments => ({ + page: comments.page, + per_page: comments.per_page, + total: comments.total, + comments: flattenCommentSavedObjects(comments.saved_objects), +}); + +export const flattenCommentSavedObjects = ( + savedObjects: SavedObjectsFindResponse<CommentAttributes>['saved_objects'] +): FlattenedCommentSavedObject[] => + savedObjects.reduce( + (acc: FlattenedCommentSavedObject[], savedObject: SavedObject<CommentAttributes>) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, + [] + ); + +export const flattenCommentSavedObject = ( + savedObject: SavedObject<CommentAttributes> +): FlattenedCommentSavedObject => ({ + comment_id: savedObject.id, + ...savedObject.attributes, +}); diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 531d5fa5b87e..d6d4bd606676 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -16,12 +16,14 @@ import { } from 'kibana/server'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; import { - NewCaseFormatted, - NewCommentFormatted, + CaseAttributes, + CommentAttributes, + SavedObjectsFindOptionsType, UpdatedCaseType, UpdatedCommentType, } from '../routes/api/types'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; +import { readTags } from './tags/read_tags'; interface ClientArgs { client: SavedObjectsClientContract; @@ -30,15 +32,19 @@ interface ClientArgs { interface GetCaseArgs extends ClientArgs { caseId: string; } + +interface GetCasesArgs extends ClientArgs { + options?: SavedObjectsFindOptionsType; +} interface GetCommentArgs extends ClientArgs { commentId: string; } interface PostCaseArgs extends ClientArgs { - attributes: NewCaseFormatted; + attributes: CaseAttributes; } interface PostCommentArgs extends ClientArgs { - attributes: NewCommentFormatted; + attributes: CommentAttributes; references: SavedObjectReference[]; } interface UpdateCaseArgs extends ClientArgs { @@ -61,15 +67,16 @@ interface CaseServiceDeps { export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; - getAllCases(args: ClientArgs): Promise<SavedObjectsFindResponse>; - getAllCaseComments(args: GetCaseArgs): Promise<SavedObjectsFindResponse>; - getCase(args: GetCaseArgs): Promise<SavedObject>; - getComment(args: GetCommentArgs): Promise<SavedObject>; + getAllCases(args: GetCasesArgs): Promise<SavedObjectsFindResponse<CaseAttributes>>; + getAllCaseComments(args: GetCaseArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>; + getCase(args: GetCaseArgs): Promise<SavedObject<CaseAttributes>>; + getComment(args: GetCommentArgs): Promise<SavedObject<CommentAttributes>>; + getTags(args: ClientArgs): Promise<string[]>; getUser(args: GetUserArgs): Promise<AuthenticatedUser>; - postNewCase(args: PostCaseArgs): Promise<SavedObject>; - postNewComment(args: PostCommentArgs): Promise<SavedObject>; - updateCase(args: UpdateCaseArgs): Promise<SavedObjectsUpdateResponse>; - updateComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse>; + postNewCase(args: PostCaseArgs): Promise<SavedObject<CaseAttributes>>; + postNewComment(args: PostCommentArgs): Promise<SavedObject<CommentAttributes>>; + updateCase(args: UpdateCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>; + updateComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>; } export class CaseService { @@ -111,10 +118,10 @@ export class CaseService { throw error; } }, - getAllCases: async ({ client }: ClientArgs) => { + getAllCases: async ({ client, options }: GetCasesArgs) => { try { this.log.debug(`Attempting to GET all cases`); - return await client.find({ type: CASE_SAVED_OBJECT }); + return await client.find({ ...options, type: CASE_SAVED_OBJECT }); } catch (error) { this.log.debug(`Error on GET cases: ${error}`); throw error; @@ -132,6 +139,15 @@ export class CaseService { throw error; } }, + getTags: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all cases`); + return await readTags({ client }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + }, getUser: async ({ request, response }: GetUserArgs) => { let user; try { diff --git a/x-pack/plugins/case/server/services/tags/read_tags.ts b/x-pack/plugins/case/server/services/tags/read_tags.ts new file mode 100644 index 000000000000..58ab99b164cf --- /dev/null +++ b/x-pack/plugins/case/server/services/tags/read_tags.ts @@ -0,0 +1,63 @@ +/* + * 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 { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { CASE_SAVED_OBJECT } from '../../constants'; +import { CaseAttributes } from '../..'; + +const DEFAULT_PER_PAGE: number = 1000; + +export const convertToTags = (tagObjects: Array<SavedObject<CaseAttributes>>): string[] => + tagObjects.reduce<string[]>((accum, tagObj) => { + if (tagObj && tagObj.attributes && tagObj.attributes.tags) { + return [...accum, ...tagObj.attributes.tags]; + } else { + return accum; + } + }, []); + +export const convertTagsToSet = (tagObjects: Array<SavedObject<CaseAttributes>>): Set<string> => { + return new Set(convertToTags(tagObjects)); +}; + +// Note: This is doing an in-memory aggregation of the tags by calling each of the alerting +// records in batches of this const setting and uses the fields to try to get the least +// amount of data per record back. If saved objects at some point supports aggregations +// then this should be replaced with a an aggregation call. +// Ref: https://www.elastic.co/guide/en/kibana/master/saved-objects-api.html +export const readTags = async ({ + client, + perPage = DEFAULT_PER_PAGE, +}: { + client: SavedObjectsClientContract; + perPage?: number; +}): Promise<string[]> => { + const tags = await readRawTags({ client, perPage }); + return tags; +}; + +export const readRawTags = async ({ + client, + perPage = DEFAULT_PER_PAGE, +}: { + client: SavedObjectsClientContract; + perPage?: number; +}): Promise<string[]> => { + const firstTags = await client.find({ + type: CASE_SAVED_OBJECT, + fields: ['tags'], + page: 1, + perPage, + }); + const tags = await client.find({ + type: CASE_SAVED_OBJECT, + fields: ['tags'], + page: 1, + perPage: firstTags.total, + }); + + return Array.from(convertTagsToSet(tags.saved_objects)); +};