[Cases] Integrate routes and navigation (#117582)

* getCases function and router

* all pages router

* navigation hooks created

* external navigations removed

* basePath in cases context

* context optimization

* no privileges screen

* new files

* CasesDeepLinkIds constant renamed

* remove props spreading

* AllCasesList tests

* Fix types and tests: Part 1

* Fix types and tests: Part 2

* Move glasses badge logic inside cases

* Fix export types

* Improve helpers

* observability changes integrated

* Small fixes

* Fix timelines unit tests

* Add readonly badge test

* test fixed

* form context test fixed

* fix breadcrumbs test

* fix types in o11y routes

* Fix more tests

* Fix bug

* urlType fixes

* Fix cypress tests

* configure header conflict solved

* Fix i18n

* fix breadcrumbs test

* tests and suggestions

* Add navigation tests

* README updated

* update plugin list docs

* Add more tests

* Fix i18n

* More tests

* Fix README

* Fix types

* fix resolve redirect paths

* fix flyout z-index on timeline

* add flyout z-index class comment

* use kibana currentAppId and application observables instead of passing props

* Get application info from the hook

* Fix tests

* Fix more tests

* tests fixed

* Fix container tests

* Fix container tests

* test updated

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

View file

@ -358,8 +358,7 @@ The plugin exposes the static DefaultEditorController class to consume.
|{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases]
|[![Issues][issues-shield]][issues-url]
[![Pull Requests][pr-shield]][pr-url]
|This plugin provides cases management in Kibana
|{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud]

View file

@ -1,11 +1,13 @@
# Case management in Kibana
# Case
This plugin provides cases management in Kibana
[![Issues][issues-shield]][issues-url]
[![Pull Requests][pr-shield]][pr-url]
# Docs
## Docs
![Cases Logo][cases-logo]
![Cases Logo][cases-logo]
[Report Bug](https://github.com/elastic/kibana/issues/new?assignees=&labels=bug&template=Bug_report.md)
·
@ -18,16 +20,18 @@
- [Cases UI](#cases-ui)
- [Case Action Type](#case-action-type) _feature in development, disabled by default_
## Cases API
[**Explore the API docs »**](https://www.elastic.co/guide/en/security/current/cases-api-overview.html)
## Cases Client API
[**Cases Client API docs**][cases-client-api-docs]
## Cases UI
#### Embed Cases UI components in any Kibana plugin
- Add `CasesUiStart` to Kibana plugin `StartServices` dependencies:
```ts
@ -37,139 +41,107 @@ cases: CasesUiStart;
#### Cases UI Methods
- From the UI component, get the component from the `useKibana` hook start services
```tsx
const { cases } = useKibana().services;
// call in the return as you would any component
cases.getCreateCase({
onCancel: handleSetIsCancel,
onSuccess,
lensIntegration?: {
plugins: {
parsingPlugin,
processingPluginRenderer,
uiPlugin,
},
hooks: {
useInsertTimeline,
},
}
timelineIntegration?: {
plugins: {
parsingPlugin,
processingPluginRenderer,
uiPlugin,
},
hooks: {
useInsertTimeline,
},
const { cases } = useKibana().services;
// call in the return as you would any component
cases.getCases({
basePath: '/investigate/cases',
userCanCrud: true,
owner: ['securitySolution'],
timelineIntegration: {
plugins: {
parsingPlugin,
processingPluginRenderer,
uiPlugin,
},
})
hooks: {
useInsertTimeline,
},
},
});
```
##### Methods:
### `getAllCases`
### `getCases`
Arguments:
| Property | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| caseDetailsNavigation | `CasesNavigation<CaseDetailsHrefSchema, 'configurable'>` route configuration to generate the case details url for the case details page |
| configureCasesNavigation | `CasesNavigation` route configuration for configure cases page |
| createCaseNavigation | `CasesNavigation` route configuration for create cases page |
| userCanCrud | `boolean;` user permissions to crud |
| Property | Description |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| userCanCrud | `boolean;` user permissions to crud |
| owner | `string[];` owner ids of the cases |
| basePath | `string;` path to mount the Cases router on top of |
| useFetchAlertData | `(alertIds: string[]) => [boolean, Record<string, Ecs>];` fetch alerts |
| disableAlerts? | `boolean` (default: false) flag to not show alerts information |
| actionsNavigation? | <code>CasesNavigation<string, 'configurable'></code> |
| ruleDetailsNavigation? | <code>CasesNavigation<string &vert; null &vert; undefined, 'configurable'></code> |
| onComponentInitialized? | `() => void;` callback when component has initialized |
| showAlertDetails? | `(alertId: string, index: string) => void;` callback to show alert details |
| timelineIntegration?.editor_plugins | Plugins needed for integrating timeline into markdown editor. |
| timelineIntegration?.editor_plugins.parsingPlugin | `Plugin;` |
| timelineIntegration?.editor_plugins.processingPluginRenderer | `React.FC<TimelineProcessingPluginRendererProps & { position: EuiMarkdownAstNodePosition }>` |
| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` |
| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` |
| timelineIntegration?.ui?.renderInvestigateInTimelineActionComponent? | `(alertIds: string[]) => JSX.Element;` space to render `InvestigateInTimelineActionComponent` |
| timelineIntegration?.ui?renderTimelineDetailsPanel? | `() => JSX.Element;` space to render `TimelineDetailsPanel` |
UI component:
![All Cases Component][all-cases-img]
### `getAllCasesSelectorModal`
Arguments:
| Property | Description |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| alertData? | `Omit<CommentRequestAlertType, 'type'>;` alert data to post to case |
| createCaseNavigation | `CasesNavigation` route configuration for create cases page |
| hiddenStatuses? | `CaseStatuses[];` array of hidden statuses |
| onRowClick | <code>(theCase?: Case &vert; SubCase) => void;</code> callback for row click, passing case in row |
| updateCase? | <code>(theCase: Case &vert; SubCase) => void;</code> callback after case has been updated |
| userCanCrud | `boolean;` user permissions to crud |
| Property | Description |
| --------------- | ------------------------------------------------------------------------------------------------- |
| userCanCrud | `boolean;` user permissions to crud |
| owner | `string[];` owner ids of the cases |
| alertData? | `Omit<CommentRequestAlertType, 'type'>;` alert data to post to case |
| hiddenStatuses? | `CaseStatuses[];` array of hidden statuses |
| onRowClick | <code>(theCase?: Case &vert; SubCase) => void;</code> callback for row click, passing case in row |
| updateCase? | <code>(theCase: Case &vert; SubCase) => void;</code> callback after case has been updated |
| onClose? | `() => void` called when the modal is closed without selecting a case |
UI component:
![All Cases Selector Modal Component][all-cases-modal-img]
### `getCaseView`
### `getCreateCaseFlyout`
Arguments:
| Property | Description |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| caseDetailsNavigation | `CasesNavigation<CaseDetailsHrefSchema, 'configurable'>` route configuration to generate the case details url for the case details page |
| caseId | `string;` ID of the case |
| configureCasesNavigation | `CasesNavigation` route configuration for configure cases page |
| createCaseNavigation | `CasesNavigation` route configuration for create cases page |
| getCaseDetailHrefWithCommentId | `(commentId: string) => string;` callback to generate the case details url with a comment id reference from the case id and comment id |
| onComponentInitialized? | `() => void;` callback when component has initialized |
| onCaseDataSuccess? | `(data: Case) => void;` optional callback to handle case data in consuming application |
| ruleDetailsNavigation | <code>CasesNavigation<string &vert; null &vert; undefined, 'configurable'></code> |
| showAlertDetails | `(alertId: string, index: string) => void;` callback to show alert details |
| subCaseId? | `string;` subcase id |
| timelineIntegration?.editor_plugins | Plugins needed for integrating timeline into markdown editor. |
| timelineIntegration?.editor_plugins.parsingPlugin | `Plugin;` |
| timelineIntegration?.editor_plugins.processingPluginRenderer | `React.FC<TimelineProcessingPluginRendererProps & { position: EuiMarkdownAstNodePosition }>` |
| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` |
| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` |
| timelineIntegration?.ui?.renderInvestigateInTimelineActionComponent? | `(alertIds: string[]) => JSX.Element;` space to render `InvestigateInTimelineActionComponent` |
| timelineIntegration?.ui?renderTimelineDetailsPanel? | `() => JSX.Element;` space to render `TimelineDetailsPanel` |
| useFetchAlertData | `(alertIds: string[]) => [boolean, Record<string, Ecs>];` fetch alerts |
| userCanCrud | `boolean;` user permissions to crud |
| Property | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| userCanCrud | `boolean;` user permissions to crud |
| owner | `string[];` owner ids of the cases |
| onClose | `() => void;` callback when create case is canceled |
| onSuccess | `(theCase: Case) => Promise<void>;` callback passing newly created case after pushCaseToExternalService is called |
| afterCaseCreated? | `(theCase: Case) => Promise<void>;` callback passing newly created case before pushCaseToExternalService is called |
| disableAlerts? | `boolean` (default: false) flag to not show alerts information |
UI component:
![Case View Component][case-view-img]
### `getCreateCase`
Arguments:
| Property | Description |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| afterCaseCreated? | `(theCase: Case) => Promise<void>;` callback passing newly created case before pushCaseToExternalService is called |
| onCancel | `() => void;` callback when create case is canceled |
| onSuccess | `(theCase: Case) => Promise<void>;` callback passing newly created case after pushCaseToExternalService is called |
| timelineIntegration?.editor_plugins | Plugins needed for integrating timeline into markdown editor. |
| timelineIntegration?.editor_plugins.parsingPlugin | `Plugin;` |
| timelineIntegration?.editor_plugins.processingPluginRenderer | `React.FC<TimelineProcessingPluginRendererProps & { position: EuiMarkdownAstNodePosition }>` |
| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` |
| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` |
UI component:
![Create Component][create-img]
### `getConfigureCases`
Arguments:
| Property | Description |
| ----------- | ------------------------------------ |
| userCanCrud | `boolean;` user permissions to crud |
UI component:
![Configure Component][configure-img]
![Create Component][create-img]
### `getRecentCases`
Arguments:
| Property | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| allCasesNavigation | `CasesNavigation` route configuration for configure cases page |
| caseDetailsNavigation | `CasesNavigation<CaseDetailsHrefSchema, 'configurable'>` route configuration to generate the case details url for the case details page |
| createCaseNavigation | `CasesNavigation` route configuration for create case page |
| maxCasesToShow | `number;` number of cases to show in widget |
| Property | Description |
| -------------- | ---------------------------------------------------- |
| userCanCrud | `boolean;` user permissions to crud |
| owner | `string[];` owner ids of the cases |
| maxCasesToShow | `number;` number of cases to show in widget |
UI component:
![Recent Cases Component][recent-cases-img]
![Recent Cases Component][recent-cases-img]
## Case Action Type
_***Feature in development, disabled by default**_
_**\*Feature in development, disabled by default**_
See [Kibana Actions](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions) for more information.
ID: `.case`
The params properties are modelled after the arguments to the [Cases API](https://www.elastic.co/guide/en/security/master/cases-api-overview.html).
@ -281,13 +253,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`.
| ---------- | ------------------------------ | ------- |
| syncAlerts | Turn on or off alert synching. | boolean |
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22
[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge

View file

@ -32,3 +32,16 @@ export const useCurrentUser = jest.fn();
export const withKibana = jest.fn(createWithKibanaMock());
export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock());
export const useGetUserSavedObjectPermissions = jest.fn();
export const useAppUrl = jest.fn().mockReturnValue({
getAppUrl: jest.fn(),
});
export const useNavigateTo = jest.fn().mockReturnValue({
navigateTo: jest.fn(),
});
export const useNavigation = jest.fn().mockReturnValue({
getAppUrl: jest.fn(),
navigateTo: jest.fn(),
});

View file

@ -15,6 +15,7 @@ import { AuthenticatedUser } from '../../../../../security/common/model';
import { convertToCamelCase } from '../../../containers/utils';
import { StartServices } from '../../../types';
import { useUiSetting, useKibana } from './kibana_react';
import { NavigateToAppOptions } from '../../../../../../../src/core/public';
export const useDateFormat = (): string => useUiSetting<string>(DEFAULT_DATE_FORMAT);
@ -104,3 +105,53 @@ export const useCurrentUser = (): AuthenticatedElasticUser | null => {
}, [fetchUser]);
return user;
};
/**
* Returns a full URL to the provided page path by using
* kibana's `getUrlForApp()`
*/
export const useAppUrl = (appId: string) => {
const { getUrlForApp } = useKibana().services.application;
const getAppUrl = useCallback(
(options?: { deepLinkId?: string; path?: string; absolute?: boolean }) =>
getUrlForApp(appId, options),
[appId, getUrlForApp]
);
return { getAppUrl };
};
/**
* Navigate to any app using kibana's `navigateToApp()`
* or by url using `navigateToUrl()`
*/
export const useNavigateTo = (appId: string) => {
const { navigateToApp, navigateToUrl } = useKibana().services.application;
const navigateTo = useCallback(
({
url,
...options
}: {
url?: string;
} & NavigateToAppOptions) => {
if (url) {
navigateToUrl(url);
} else {
navigateToApp(appId, options);
}
},
[appId, navigateToApp, navigateToUrl]
);
return { navigateTo };
};
/**
* Returns navigateTo and getAppUrl navigation hooks
*
*/
export const useNavigation = (appId: string) => {
const { navigateTo } = useNavigateTo(appId);
const { getAppUrl } = useAppUrl(appId);
return { navigateTo, getAppUrl };
};

View file

@ -9,6 +9,7 @@
import React from 'react';
import { PublicAppInfo } from 'kibana/public';
import { RecursivePartial } from '@elastic/eui/src/components/common';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
@ -17,11 +18,12 @@ import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common';
import { securityMock } from '../../../../../security/public/mocks';
import { spacesPluginMock } from '../../../../../spaces/public/mocks';
import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks';
import { BehaviorSubject } from 'rxjs';
export const createStartServicesMock = (): StartServices =>
({
export const createStartServicesMock = (): StartServices => {
const services = {
...coreMock.createStart(),
storage: { ...coreMock.createStorage(), remove: jest.fn() },
storage: { ...coreMock.createStorage(), get: jest.fn(), set: jest.fn(), remove: jest.fn() },
lens: {
canUseEditor: jest.fn(),
navigateToPrefilledEditor: jest.fn(),
@ -29,7 +31,15 @@ export const createStartServicesMock = (): StartServices =>
security: securityMock.createStart(),
triggersActionsUi: triggersActionsUiMock.createStart(),
spaces: spacesPluginMock.createStartContract(),
} as unknown as StartServices);
} as unknown as StartServices;
services.application.currentAppId$ = new BehaviorSubject<string>('testAppId');
services.application.applications$ = new BehaviorSubject<Map<string, PublicAppInfo>>(
new Map([['testAppId', { category: { label: 'Test' } } as unknown as PublicAppInfo]])
);
return services;
};
export const createWithKibanaMock = () => {
const services = createStartServicesMock();

View file

@ -8,31 +8,28 @@
import { euiDarkVars } from '@kbn/ui-shared-deps-src/theme';
import { I18nProvider } from '@kbn/i18n/react';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { ThemeProvider } from 'styled-components';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { OwnerProvider } from '../../components/owner_context';
import {
createKibanaContextProviderMock,
createStartServicesMock,
} from '../lib/kibana/kibana_react.mock';
import { CasesProvider } from '../../components/cases_context';
import { createKibanaContextProviderMock } from '../lib/kibana/kibana_react.mock';
import { FieldHook } from '../shared_imports';
interface Props {
children: React.ReactNode;
userCanCrud?: boolean;
}
export const kibanaObservable = new BehaviorSubject(createStartServicesMock());
window.scrollTo = jest.fn();
const MockKibanaContextProvider = createKibanaContextProviderMock();
/** A utility for wrapping children in the providers required to run most tests */
const TestProvidersComponent: React.FC<Props> = ({ children }) => (
const TestProvidersComponent: React.FC<Props> = ({ children, userCanCrud = true }) => (
<I18nProvider>
<MockKibanaContextProvider>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<OwnerProvider owner={[SECURITY_SOLUTION_OWNER]}>{children}</OwnerProvider>
<CasesProvider value={{ owner: [SECURITY_SOLUTION_OWNER], userCanCrud }}>
{children}
</CasesProvider>
</ThemeProvider>
</MockKibanaContextProvider>
</I18nProvider>

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const useCaseViewParams = jest.fn().mockReturnValue({ detailName: 'basic-case-id' });
export const useAllCasesNavigation = jest.fn().mockReturnValue({
getAllCasesUrl: jest.fn().mockReturnValue('/app/security/cases'),
navigateToAllCases: jest.fn(),
});
export const useCreateCaseNavigation = jest.fn().mockReturnValue({
getCreateCaseUrl: jest.fn().mockReturnValue('/app/security/cases/create'),
navigateToCreateCase: jest.fn(),
});
export const useCaseViewNavigation = jest.fn().mockReturnValue({
getCaseViewUrl: jest.fn().mockReturnValue('/app/security/cases/test'),
navigateToCaseView: jest.fn(),
});
export const useConfigureCasesNavigation = jest.fn().mockReturnValue({
getConfigureCasesUrl: jest.fn().mockReturnValue('/app/security/cases/configure'),
navigateToConfigureCases: jest.fn(),
});

View file

@ -0,0 +1,162 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AppNavLinkStatus } from '../../../../../../src/core/public';
import { getCasesDeepLinks } from './deep_links';
describe('getCasesDeepLinks', () => {
it('it returns the deep links', () => {
const deepLinks = getCasesDeepLinks({});
expect(deepLinks).toEqual({
id: 'cases',
path: '/cases',
title: 'Cases',
deepLinks: [
{
id: 'cases_create',
path: '/cases/create',
title: 'Create New Case',
},
{
id: 'cases_configure',
path: '/cases/configure',
title: 'Configure Cases',
},
],
});
});
it('it returns the deep links with a different base bath', () => {
const deepLinks = getCasesDeepLinks({ basePath: '/test' });
expect(deepLinks).toEqual({
id: 'cases',
path: '/test',
title: 'Cases',
deepLinks: [
{
id: 'cases_create',
path: '/test/create',
title: 'Create New Case',
},
{
id: 'cases_configure',
path: '/test/configure',
title: 'Configure Cases',
},
],
});
});
it('it extends the deep links correctly', () => {
const deepLinks = getCasesDeepLinks({
extend: {
cases: {
searchable: false,
},
cases_create: {
navLinkStatus: AppNavLinkStatus.hidden,
},
cases_configure: {
order: 8002,
},
},
});
expect(deepLinks).toEqual({
id: 'cases',
path: '/cases',
title: 'Cases',
searchable: false,
deepLinks: [
{
id: 'cases_create',
path: '/cases/create',
title: 'Create New Case',
navLinkStatus: AppNavLinkStatus.hidden,
},
{
id: 'cases_configure',
path: '/cases/configure',
title: 'Configure Cases',
order: 8002,
},
],
});
});
it('it does not overrides the id, the path, and the deepLinks', () => {
const deepLinks = getCasesDeepLinks({
extend: {
cases: {
id: 'cases_override',
path: 'cases_path_override',
deepLinks: [],
},
cases_create: {
id: 'cases_create_override',
path: 'cases_create_path_override',
},
cases_configure: {
id: 'cases_configure_override',
path: 'cases_configure_path_override',
},
},
});
expect(deepLinks).toEqual({
id: 'cases',
path: '/cases',
title: 'Cases',
deepLinks: [
{
id: 'cases_create',
path: '/cases/create',
title: 'Create New Case',
},
{
id: 'cases_configure',
path: '/cases/configure',
title: 'Configure Cases',
},
],
});
});
it('it overrides the title correctly', () => {
const deepLinks = getCasesDeepLinks({
extend: {
cases: {
title: 'My new cases title',
},
cases_create: {
title: 'My new create cases title',
},
cases_configure: {
title: 'My new configure cases title',
},
},
});
expect(deepLinks).toEqual({
id: 'cases',
path: '/cases',
title: 'My new cases title',
deepLinks: [
{
id: 'cases_create',
path: '/cases/create',
title: 'My new create cases title',
},
{
id: 'cases_configure',
path: '/cases/configure',
title: 'My new configure cases title',
},
],
});
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { AppDeepLink } from '../../../../../../src/core/public';
import { DEFAULT_BASE_PATH, getCreateCasePath, getCasesConfigurePath } from './paths';
export const CasesDeepLinkId = {
cases: 'cases',
casesCreate: 'cases_create',
casesConfigure: 'cases_configure',
} as const;
export type ICasesDeepLinkId = typeof CasesDeepLinkId[keyof typeof CasesDeepLinkId];
export const getCasesDeepLinks = <T extends AppDeepLink = AppDeepLink>({
basePath = DEFAULT_BASE_PATH,
extend = {},
}: {
basePath?: string;
extend?: Partial<Record<ICasesDeepLinkId, Partial<T>>>;
}) => ({
title: i18n.translate('xpack.cases.navigation.cases', {
defaultMessage: 'Cases',
}),
...(extend[CasesDeepLinkId.cases] ?? {}),
id: CasesDeepLinkId.cases,
path: basePath,
deepLinks: [
{
title: i18n.translate('xpack.cases.navigation.create', {
defaultMessage: 'Create New Case',
}),
...(extend[CasesDeepLinkId.casesCreate] ?? {}),
id: CasesDeepLinkId.casesCreate,
path: getCreateCasePath(basePath),
},
{
title: i18n.translate('xpack.cases.navigation.configure', {
defaultMessage: 'Configure Cases',
}),
...(extend[CasesDeepLinkId.casesConfigure] ?? {}),
id: CasesDeepLinkId.casesConfigure,
path: getCasesConfigurePath(basePath),
},
],
});

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react-hooks';
import { useNavigation } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import {
useCasesNavigation,
useAllCasesNavigation,
useCreateCaseNavigation,
useConfigureCasesNavigation,
useCaseViewNavigation,
} from './hooks';
import { CasesDeepLinkId } from './deep_links';
const useNavigationMock = useNavigation as jest.Mock;
jest.mock('../../common/lib/kibana');
const navigateTo = jest.fn();
const getAppUrl = jest.fn();
describe('hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
useNavigationMock.mockReturnValue({ navigateTo, getAppUrl });
});
describe('useCasesNavigation', () => {
it('it calls getAppUrl with correct arguments', () => {
const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
const [getCasesUrl] = result.current;
act(() => {
getCasesUrl(false);
});
expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' });
});
it('it calls navigateToAllCases with correct arguments', () => {
const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
const [, navigateToCases] = result.current;
act(() => {
navigateToCases();
});
expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' });
});
});
describe('useAllCasesNavigation', () => {
it('it calls getAppUrl with correct arguments', () => {
const { result } = renderHook(() => useAllCasesNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.getAllCasesUrl(false);
});
expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' });
});
it('it calls navigateToAllCases with correct arguments', () => {
const { result } = renderHook(() => useAllCasesNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.navigateToAllCases();
});
expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' });
});
});
describe('useCreateCaseNavigation', () => {
it('it calls getAppUrl with correct arguments', () => {
const { result } = renderHook(() => useCreateCaseNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.getCreateCaseUrl(false);
});
expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_create' });
});
it('it calls navigateToAllCases with correct arguments', () => {
const { result } = renderHook(() => useCreateCaseNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.navigateToCreateCase();
});
expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_create' });
});
});
describe('useConfigureCasesNavigation', () => {
it('it calls getAppUrl with correct arguments', () => {
const { result } = renderHook(() => useConfigureCasesNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.getConfigureCasesUrl(false);
});
expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_configure' });
});
it('it calls navigateToAllCases with correct arguments', () => {
const { result } = renderHook(() => useConfigureCasesNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.navigateToConfigureCases();
});
expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_configure' });
});
});
describe('useCaseViewNavigation', () => {
it('it calls getAppUrl with correct arguments', () => {
const { result } = renderHook(() => useCaseViewNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.getCaseViewUrl({ detailName: 'test' }, false);
});
expect(getAppUrl).toHaveBeenCalledWith({
absolute: false,
deepLinkId: 'cases',
path: '/test',
});
});
it('it calls navigateToAllCases with correct arguments', () => {
const { result } = renderHook(() => useCaseViewNavigation(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
result.current.navigateToCaseView({ detailName: 'test' });
});
expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases', path: '/test' });
});
});
});

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigation } from '../lib/kibana';
import { useCasesContext } from '../../components/cases_context/use_cases_context';
import { CasesDeepLinkId, ICasesDeepLinkId } from './deep_links';
import { CaseViewPathParams, generateCaseViewPath } from './paths';
export const useCaseViewParams = () => useParams<CaseViewPathParams>();
type GetCasesUrl = (absolute?: boolean) => string;
type NavigateToCases = () => void;
type UseCasesNavigation = [GetCasesUrl, NavigateToCases];
export const useCasesNavigation = (deepLinkId: ICasesDeepLinkId): UseCasesNavigation => {
const { appId } = useCasesContext();
const { navigateTo, getAppUrl } = useNavigation(appId);
const getCasesUrl = useCallback<GetCasesUrl>(
(absolute) => getAppUrl({ deepLinkId, absolute }),
[getAppUrl, deepLinkId]
);
const navigateToCases = useCallback<NavigateToCases>(
() => navigateTo({ deepLinkId }),
[navigateTo, deepLinkId]
);
return [getCasesUrl, navigateToCases];
};
export const useAllCasesNavigation = () => {
const [getAllCasesUrl, navigateToAllCases] = useCasesNavigation(CasesDeepLinkId.cases);
return { getAllCasesUrl, navigateToAllCases };
};
export const useCreateCaseNavigation = () => {
const [getCreateCaseUrl, navigateToCreateCase] = useCasesNavigation(CasesDeepLinkId.casesCreate);
return { getCreateCaseUrl, navigateToCreateCase };
};
export const useConfigureCasesNavigation = () => {
const [getConfigureCasesUrl, navigateToConfigureCases] = useCasesNavigation(
CasesDeepLinkId.casesConfigure
);
return { getConfigureCasesUrl, navigateToConfigureCases };
};
type GetCaseViewUrl = (pathParams: CaseViewPathParams, absolute?: boolean) => string;
type NavigateToCaseView = (pathParams: CaseViewPathParams) => void;
export const useCaseViewNavigation = () => {
const { appId } = useCasesContext();
const { navigateTo, getAppUrl } = useNavigation(appId);
const getCaseViewUrl = useCallback<GetCaseViewUrl>(
(pathParams, absolute) =>
getAppUrl({
deepLinkId: CasesDeepLinkId.cases,
absolute,
path: generateCaseViewPath(pathParams),
}),
[getAppUrl]
);
const navigateToCaseView = useCallback<NavigateToCaseView>(
(pathParams) =>
navigateTo({ deepLinkId: CasesDeepLinkId.cases, path: generateCaseViewPath(pathParams) }),
[navigateTo]
);
return { getCaseViewUrl, navigateToCaseView };
};

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import md5 from 'md5';
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));
export * from './deep_links';
export * from './paths';
export * from './hooks';

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getCreateCasePath,
getSubCaseViewPath,
getCaseViewPath,
getCasesConfigurePath,
getCaseViewWithCommentPath,
getSubCaseViewWithCommentPath,
generateCaseViewPath,
} from './paths';
describe('Paths', () => {
describe('getCreateCasePath', () => {
it('returns the correct path', () => {
expect(getCreateCasePath('test')).toBe('test/create');
});
});
describe('getCasesConfigurePath', () => {
it('returns the correct path', () => {
expect(getCasesConfigurePath('test')).toBe('test/configure');
});
});
describe('getCaseViewPath', () => {
it('returns the correct path', () => {
expect(getCaseViewPath('test')).toBe('test/:detailName');
});
});
describe('getSubCaseViewPath', () => {
it('returns the correct path', () => {
expect(getSubCaseViewPath('test')).toBe('test/:detailName/sub-cases/:subCaseId');
});
});
describe('getCaseViewWithCommentPath', () => {
it('returns the correct path', () => {
expect(getCaseViewWithCommentPath('test')).toBe('test/:detailName/:commentId');
});
});
describe('getSubCaseViewWithCommentPath', () => {
it('returns the correct path', () => {
expect(getSubCaseViewWithCommentPath('test')).toBe(
'test/:detailName/sub-cases/:subCaseId/:commentId'
);
});
});
describe('generateCaseViewPath', () => {
it('returns the correct path', () => {
expect(generateCaseViewPath({ detailName: 'my-case' })).toBe('/my-case');
});
it('returns the correct path when subCaseId is provided', () => {
expect(generateCaseViewPath({ detailName: 'my-case', subCaseId: 'my-sub-case' })).toBe(
'/my-case/sub-cases/my-sub-case'
);
});
it('returns the correct path when commentId is provided', () => {
expect(generateCaseViewPath({ detailName: 'my-case', commentId: 'my-comment' })).toBe(
'/my-case/my-comment'
);
});
it('returns the correct path when subCaseId and commentId is provided', () => {
expect(
generateCaseViewPath({
detailName: 'my-case',
subCaseId: 'my-sub-case',
commentId: 'my-comment',
})
).toBe('/my-case/sub-cases/my-sub-case/my-comment');
});
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { generatePath } from 'react-router-dom';
export const DEFAULT_BASE_PATH = '/cases';
export interface CaseViewPathParams {
detailName: string;
subCaseId?: string;
commentId?: string;
}
export const CASES_CREATE_PATH = '/create' as const;
export const CASES_CONFIGURE_PATH = '/configure' as const;
export const CASE_VIEW_PATH = '/:detailName' as const;
export const SUB_CASE_VIEW_PATH = `${CASE_VIEW_PATH}/sub-cases/:subCaseId` as const;
export const CASE_VIEW_COMMENT_PATH = `${CASE_VIEW_PATH}/:commentId` as const;
export const SUB_CASE_VIEW_COMMENT_PATH = `${SUB_CASE_VIEW_PATH}/:commentId` as const;
export const getCreateCasePath = (casesBasePath: string) => `${casesBasePath}${CASES_CREATE_PATH}`;
export const getCasesConfigurePath = (casesBasePath: string) =>
`${casesBasePath}${CASES_CONFIGURE_PATH}`;
export const getCaseViewPath = (casesBasePath: string) => `${casesBasePath}${CASE_VIEW_PATH}`;
export const getSubCaseViewPath = (casesBasePath: string) =>
`${casesBasePath}${SUB_CASE_VIEW_PATH}`;
export const getCaseViewWithCommentPath = (casesBasePath: string) =>
`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`;
export const getSubCaseViewWithCommentPath = (casesBasePath: string) =>
`${casesBasePath}${SUB_CASE_VIEW_COMMENT_PATH}`;
export const generateCaseViewPath = (params: CaseViewPathParams): string => {
const { subCaseId, commentId } = params;
// Cast for generatePath argument type constraint
const pathParams = params as unknown as { [paramName: string]: string };
if (subCaseId && commentId) {
return generatePath(SUB_CASE_VIEW_COMMENT_PATH, pathParams);
}
if (subCaseId) {
return generatePath(SUB_CASE_VIEW_PATH, pathParams);
}
if (commentId) {
return generatePath(CASE_VIEW_COMMENT_PATH, pathParams);
}
return generatePath(CASE_VIEW_PATH, pathParams);
};

View file

@ -26,7 +26,7 @@ import { Form, useForm, UseField, useFormData } from '../../common/shared_import
import * as i18n from './translations';
import { schema, AddCommentFormSchema } from './schema';
import { InsertTimeline } from '../insert_timeline';
import { useOwnerContext } from '../owner_context/use_owner_context';
import { useCasesContext } from '../cases_context/use_cases_context';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@ -72,7 +72,7 @@ export const AddComment = React.memo(
) => {
const editorRef = useRef<EuiMarkdownEditorRef>(null);
const [focusOnContext, setFocusOnContext] = useState(false);
const owner = useOwnerContext();
const { owner } = useCasesContext();
const { isLoading, postComment } = usePostComment();
const { form } = useForm<AddCommentFormSchema>({

View file

@ -1,101 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { AllCasesGeneric } from './all_cases_generic';
import { TestProviders } from '../../common/mock';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useKibana } from '../../common/lib/kibana';
import { StatusAll } from '../../containers/types';
import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common';
import { connectorsMock } from '../../containers/mock';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/use_get_action_license');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/api');
jest.mock('../../common/lib/kibana');
const createCaseNavigation = { href: '', onClick: jest.fn() };
const alertDataMock = {
type: 'alert',
rule: {
id: 'rule-id',
name: 'rule',
},
index: 'index-id',
alertId: 'alert-id',
owner: SECURITY_SOLUTION_OWNER,
};
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useConnectorsMock = useConnectors as jest.Mock;
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
triggersActionsUi: mockTriggersActionsUiService,
},
}),
};
});
describe('AllCasesGeneric ', () => {
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
beforeEach(() => {
jest.resetAllMocks();
(useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() });
(useGetReporters as jest.Mock).mockReturnValue({
reporters: ['casetester'],
respReporters: [{ username: 'casetester' }],
isLoading: true,
isError: false,
fetchReporters: jest.fn(),
});
(useGetActionLicense as jest.Mock).mockReturnValue({
actionLicense: null,
isLoading: false,
});
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
});
it('renders the first available status when hiddenStatus is given', () =>
act(async () => {
const wrapper = mount(
<TestProviders>
<AllCasesGeneric
alertData={alertDataMock}
createCaseNavigation={createCaseNavigation}
hiddenStatuses={[StatusAll, CaseStatuses.open]}
isSelectorView={true}
userCanCrud={true}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="status-badge-in-progress"]`).exists()).toBeTruthy();
}));
});

View file

@ -0,0 +1,782 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import moment from 'moment-timezone';
import { act, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import '../../common/mock/match_media';
import { TestProviders } from '../../common/mock';
import {
casesStatus,
useGetCasesMockState,
collectionCase,
connectorsMock,
} from '../../containers/mock';
import { CaseStatuses, CaseType, SECURITY_SOLUTION_OWNER, StatusAll } from '../../../common';
import { getEmptyTagValue } from '../empty_value';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useKibana } from '../../common/lib/kibana';
import { AllCasesList, AllCasesListProps } from './all_cases_list';
import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock';
jest.mock('../../containers/use_bulk_update_case');
jest.mock('../../containers/use_delete_cases');
jest.mock('../../containers/use_get_cases');
jest.mock('../../containers/use_get_cases_status');
jest.mock('../../containers/use_get_action_license');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
const useDeleteCasesMock = useDeleteCases as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
const useGetActionLicenseMock = useGetActionLicense as jest.Mock;
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const useConnectorsMock = useConnectors as jest.Mock;
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...createStartServicesMock(),
triggersActionsUi: mockTriggersActionsUiService,
},
} as unknown as ReturnType<typeof useKibana>);
};
describe('AllCasesListGeneric', () => {
const defaultAllCasesListProps: AllCasesListProps = {
disableAlerts: false,
};
const dispatchResetIsDeleted = jest.fn();
const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const refetchCases = jest.fn();
const setFilters = jest.fn();
const setQueryParams = jest.fn();
const setSelectedCases = jest.fn();
const updateBulkStatus = jest.fn();
const fetchCasesStatus = jest.fn();
const onRowClick = jest.fn();
const emptyTag = getEmptyTagValue().props.children;
const defaultGetCases = {
...useGetCasesMockState,
dispatchUpdateCaseProperty,
refetchCases,
setFilters,
setQueryParams,
setSelectedCases,
};
const defaultDeleteCases = {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isDeleted: false,
isDisplayConfirmDeleteModal: false,
isLoading: false,
};
const defaultCasesStatus = {
...casesStatus,
fetchCasesStatus,
isError: false,
isLoading: false,
};
const defaultUpdateCases = {
isUpdated: false,
isLoading: false,
isError: false,
dispatchResetIsUpdated,
updateBulkStatus,
};
const defaultActionLicense = {
actionLicense: null,
isLoading: false,
isError: false,
};
const defaultColumnArgs = {
caseDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
dispatchUpdateCaseProperty: jest.fn,
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
isLoadingCases: [],
isSelectorView: false,
userCanCrud: true,
};
beforeAll(() => {
mockKibana();
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
beforeEach(() => {
jest.clearAllMocks();
useUpdateCasesMock.mockReturnValue(defaultUpdateCases);
useGetCasesMock.mockReturnValue(defaultGetCases);
useDeleteCasesMock.mockReturnValue(defaultDeleteCases);
useGetCasesStatusMock.mockReturnValue(defaultCasesStatus);
useGetActionLicenseMock.mockReturnValue(defaultActionLicense);
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
mockKibana();
moment.tz.setDefault('UTC');
});
it('should render AllCasesList', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().prop('href')).toEqual(
`/app/security/cases/test`
);
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().text()).toEqual(
useGetCasesMockState.data.cases[0].title
);
expect(
wrapper.find(`span[data-test-subj="case-table-column-tags-0"]`).first().prop('title')
).toEqual(useGetCasesMockState.data.cases[0].tags[0]);
expect(wrapper.find(`[data-test-subj="case-table-column-createdBy"]`).first().text()).toEqual(
useGetCasesMockState.data.cases[0].createdBy.fullName
);
expect(
wrapper
.find(`[data-test-subj="case-table-column-createdAt"]`)
.first()
.childAt(0)
.prop('value')
).toBe(useGetCasesMockState.data.cases[0].createdAt);
expect(wrapper.find(`[data-test-subj="case-table-case-count"]`).first().text()).toEqual(
'Showing 10 cases'
);
});
});
it('should render empty fields', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
data: {
...defaultGetCases.data,
cases: [
{
...defaultGetCases.data.cases[0],
id: null,
createdAt: null,
createdBy: null,
status: null,
subCases: null,
tags: null,
title: null,
totalComment: null,
totalAlerts: null,
},
],
},
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
const checkIt = (columnName: string, key: number) => {
const column = wrapper.find('[data-test-subj="cases-table"] tbody .euiTableRowCell').at(key);
expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName);
expect(column.find('span').text()).toEqual(emptyTag);
};
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns(defaultColumnArgs)
);
await waitFor(() => {
result.current.map(
(i, key) =>
i.name != null &&
!Object.prototype.hasOwnProperty.call(i, 'actions') &&
checkIt(`${i.name}`, key)
);
});
});
it('should render delete actions for case', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toBeFalsy();
});
});
it.skip('should enable correct actions for sub cases', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
cases: [
{
...defaultGetCases.data.cases[0],
id: 'my-case-with-subcases',
createdAt: null,
createdBy: null,
status: null,
subCases: [
{
id: 'sub-case-id',
},
],
tags: null,
title: null,
totalComment: null,
totalAlerts: null,
type: CaseType.collection,
},
],
},
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toEqual(
false
);
});
it('should tableHeaderSortButton AllCasesList', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="tableHeaderSortButton"]').first().simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
page: 1,
perPage: 5,
sortField: 'createdAt',
sortOrder: 'asc',
});
});
});
it('Updates status when status context menu is updated', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).first().simulate('click');
wrapper
.find(`[data-test-subj="case-view-status-dropdown-closed"] button`)
.first()
.simulate('click');
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.closed,
version: firstCase.version,
})
);
});
});
it.skip('Bulk delete', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed },
selectedCases: [...useGetCasesMockState.data.cases, collectionCase],
});
useDeleteCasesMock
.mockReturnValueOnce({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: false,
})
.mockReturnValue({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().simulate('click');
wrapper
.find(
'[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]'
)
.last()
.simulate('click');
await waitFor(() => {
expect(handleToggleModal).toBeCalled();
expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual([
...useGetCasesMockState.data.cases.map(({ id, type, title }) => ({ id, type, title })),
{
id: collectionCase.id,
title: collectionCase.title,
type: collectionCase.type,
},
]);
});
});
it('Renders only bulk delete on status all', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: StatusAll },
selectedCases: [...useGetCasesMockState.data.cases],
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').exists()).toEqual(
false
);
expect(wrapper.find('[data-test-subj="cases-bulk-close-button"]').exists()).toEqual(false);
expect(
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled
).toEqual(false);
});
});
it('Renders correct bulk actions for case collection when filter status is set to all - enable only bulk delete if any collection is selected', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: [
...useGetCasesMockState.data.cases,
{
...useGetCasesMockState.data.cases[0],
type: CaseType.collection,
},
],
});
useDeleteCasesMock
.mockReturnValueOnce({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: false,
})
.mockReturnValue({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false);
expect(
wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().props().disabled
).toEqual(true);
expect(
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().props().disabled
).toEqual(true);
expect(
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled
).toEqual(false);
});
});
it('Bulk close status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: useGetCasesMockState.data.cases,
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed);
});
});
it('Bulk open status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
selectedCases: useGetCasesMockState.data.cases,
filterOptions: {
...defaultGetCases.filterOptions,
status: CaseStatuses.closed,
},
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open);
});
});
it('Bulk in-progress status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: useGetCasesMockState.data.cases,
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(
useGetCasesMockState.data.cases,
CaseStatuses['in-progress']
);
});
});
it('isDeleted is true, refetch', async () => {
useDeleteCasesMock.mockReturnValue({
...defaultDeleteCases,
isDeleted: true,
});
mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
await waitFor(() => {
expect(refetchCases).toBeCalled();
// expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsDeleted).toBeCalled();
});
});
it('isUpdated is true, refetch', async () => {
useUpdateCasesMock.mockReturnValue({
...defaultUpdateCases,
isUpdated: true,
});
mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} />
</TestProviders>
);
await waitFor(() => {
expect(refetchCases).toBeCalled();
// expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsUpdated).toBeCalled();
});
});
it('should not render table utility bar when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-table-selected-case-count"]').exists()).toBe(
false
);
expect(wrapper.find('[data-test-subj="case-table-bulk-actions"]').exists()).toBe(false);
});
});
it('case table should not be selectable when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-table"]').first().prop('isSelectable')).toBe(
false
);
});
});
it('should call onRowClick with no cases and isSelectorView=true', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
total: 0,
cases: [],
},
});
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={true} onRowClick={onRowClick} />
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).toHaveBeenCalled();
});
});
it('should call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={true} onRowClick={onRowClick} />
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).toHaveBeenCalledWith({
closedAt: null,
closedBy: null,
comments: [],
connector: { fields: null, id: '123', name: 'My Connector', type: '.jira' },
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
description: 'Security banana Issue',
externalService: {
connectorId: '123',
connectorName: 'connector name',
externalId: 'external_id',
externalTitle: 'external title',
externalUrl: 'basicPush.com',
pushedAt: '2020-02-20T15:02:57.995Z',
pushedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
},
id: '1',
owner: SECURITY_SOLUTION_OWNER,
status: 'open',
subCaseIds: [],
tags: ['coke', 'pepsi'],
title: 'Another horrible breach!!',
totalAlerts: 0,
totalComment: 0,
type: CaseType.individual,
updatedAt: '2020-02-20T15:02:57.995Z',
updatedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
version: 'WzQ3LDFd',
settings: {
syncAlerts: true,
},
});
});
});
it('should NOT call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).not.toHaveBeenCalled();
});
});
it('should change the status to closed', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'closedAt',
});
});
});
it('should change the status to in-progress', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'createdAt',
});
});
});
it('should change the status to open', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'createdAt',
});
});
});
it('should show the correct count on stats', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
await waitFor(() => {
expect(wrapper.find('button[data-test-subj="case-status-filter-open"]').text()).toBe(
'Open (20)'
);
expect(wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').text()).toBe(
'In progress (40)'
);
expect(wrapper.find('button[data-test-subj="case-status-filter-closed"]').text()).toBe(
'Closed (130)'
);
});
});
it('should not render status when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={true} />
</TestProviders>
);
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns({
...defaultColumnArgs,
isSelectorView: true,
})
);
expect(result.current.find((i) => i.name === 'Status')).toBeFalsy();
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-table"]').exists()).toBeTruthy();
});
expect(wrapper.find('[data-test-subj="case-view-status-dropdown"]').exists()).toBeFalsy();
});
it.skip('renders the first available status when hiddenStatus is given', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesList hiddenStatuses={[StatusAll, CaseStatuses.open]} isSelectorView={true} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="status-badge-in-progress"]').exists()).toBeTruthy();
});
it('should call doRefresh if provided', async () => {
const doRefresh = jest.fn();
const wrapper = mount(
<TestProviders>
<AllCasesList {...defaultAllCasesListProps} isSelectorView={false} doRefresh={doRefresh} />
</TestProviders>
);
await act(async () => {
wrapper.find('[data-test-subj="all-cases-refresh"] button').first().simulate('click');
});
expect(doRefresh).toHaveBeenCalled();
});
});

View file

@ -23,20 +23,17 @@ import {
caseStatuses,
} from '../../../common';
import { SELECTABLE_MESSAGE_COLLECTIONS } from '../../common/translations';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { useGetCases } from '../../containers/use_get_cases';
import { usePostComment } from '../../containers/use_post_comment';
import { CaseDetailsHrefSchema, CasesNavigation } from '../links';
import { getActionLicenseError } from '../use_push_to_service/helpers';
import { useCasesColumns } from './columns';
import { getExpandedRowMap } from './expanded_row';
import { CasesTableHeader } from './header';
import { CasesTableFilters } from './table_filters';
import { EuiBasicTableOnChange } from './types';
import { CasesTable } from './table';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useCasesContext } from '../cases_context/use_cases_context';
const ProgressLoader = styled(EuiProgress)`
${({ $isShow }: { $isShow: boolean }) =>
@ -54,36 +51,27 @@ const ProgressLoader = styled(EuiProgress)`
const getSortField = (field: string): SortFieldCase =>
field === SortFieldCase.closedAt ? SortFieldCase.closedAt : SortFieldCase.createdAt;
interface AllCasesGenericProps {
export interface AllCasesListProps {
alertData?: Omit<CommentRequestAlertType, 'type'>;
caseDetailsNavigation?: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; // if not passed, case name is not displayed as a link (Formerly dependant on isSelectorView)
configureCasesNavigation?: CasesNavigation; // if not passed, header with nav is not displayed (Formerly dependant on isSelectorView)
createCaseNavigation: CasesNavigation;
disableAlerts?: boolean;
hiddenStatuses?: CaseStatusWithAllStatus[];
isSelectorView?: boolean;
onRowClick?: (theCase?: Case | SubCase) => void;
showTitle?: boolean;
updateCase?: (newCase: Case) => void;
userCanCrud: boolean;
doRefresh?: () => void;
}
export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
export const AllCasesList = React.memo<AllCasesListProps>(
({
alertData,
caseDetailsNavigation,
configureCasesNavigation,
createCaseNavigation,
disableAlerts,
hiddenStatuses = [],
isSelectorView,
isSelectorView = false,
onRowClick,
showTitle,
updateCase,
userCanCrud,
doRefresh,
}) => {
const { actionLicense } = useGetActionLicense();
const { userCanCrud } = useCasesContext();
const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses));
const initialFilterOptions =
!isEmpty(hiddenStatuses) && firstAvailableStatus ? { status: firstAvailableStatus } : {};
@ -120,34 +108,19 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
},
[filterRefetch]
);
const [refresh, doRefresh] = useState<number>(0);
const [isLoading, handleIsLoading] = useState<boolean>(false);
const refreshCases = useCallback(
(dataRefresh = true) => {
if (dataRefresh) refetchCases();
doRefresh((prev) => prev + 1);
if (doRefresh) doRefresh();
setSelectedCases([]);
if (filterRefetch.current != null) {
filterRefetch.current();
}
},
[filterRefetch, refetchCases, setSelectedCases]
[doRefresh, filterRefetch, refetchCases, setSelectedCases]
);
const { onClick: onCreateCaseNavClick } = createCaseNavigation;
const goToCreateCase = useCallback(
(ev) => {
ev.preventDefault();
if (isSelectorView && onRowClick != null) {
onRowClick();
} else if (onCreateCaseNavClick) {
onCreateCaseNavClick(ev);
}
},
[isSelectorView, onCreateCaseNavClick, onRowClick]
);
const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
let newQueryParams = queryParams;
@ -195,15 +168,13 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
const showActions = userCanCrud && !isSelectorView;
const columns = useCasesColumns({
caseDetailsNavigation,
disableAlerts,
dispatchUpdateCaseProperty,
filterStatus: filterOptions.status,
handleIsLoading,
isLoadingCases: loading,
refreshCases,
// isSelectorView is boolean | undefined. We need to convert it to a boolean.
isSelectorView: !!isSelectorView,
isSelectorView,
userCanCrud,
connectors,
onRowClick,
@ -253,16 +224,6 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
return (
<>
{configureCasesNavigation != null && (
<CasesTableHeader
actionsErrors={actionsErrors}
createCaseNavigation={createCaseNavigation}
configureCasesNavigation={configureCasesNavigation}
refresh={refresh}
showTitle={showTitle}
userCanCrud={userCanCrud}
/>
)}
<ProgressLoader
size="xs"
color="accent"
@ -285,10 +246,9 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
/>
<CasesTable
columns={columns}
createCaseNavigation={createCaseNavigation}
data={data}
filterOptions={filterOptions}
goToCreateCase={goToCreateCase}
goToCreateCase={onRowClick}
handleIsLoading={handleIsLoading}
isCasesLoading={isCasesLoading}
isCommentUpdating={isCommentUpdating}
@ -311,4 +271,4 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
}
);
AllCasesGeneric.displayName = 'AllCasesGeneric';
AllCasesList.displayName = 'AllCasesList';

View file

@ -34,7 +34,7 @@ import {
} from '../../../common';
import { getEmptyTagValue } from '../empty_value';
import { FormattedRelativePreferenceDate } from '../formatted_date';
import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links';
import { CaseDetailsLink } from '../links';
import * as i18n from './translations';
import { getSubCasesStatusCountsBadges, isSubCase } from './helpers';
import { ALERTS } from '../../common/translations';
@ -69,7 +69,6 @@ const renderStringField = (field: string, dataTestSubj: string) =>
field != null ? <span data-test-subj={dataTestSubj}>{field}</span> : getEmptyTagValue();
export interface GetCasesColumn {
caseDetailsNavigation?: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
disableAlerts?: boolean;
dispatchUpdateCaseProperty: (u: UpdateCase) => void;
filterStatus: string;
@ -85,7 +84,6 @@ export interface GetCasesColumn {
updateCase?: (newCase: Case) => void;
}
export const useCasesColumns = ({
caseDetailsNavigation,
disableAlerts = false,
dispatchUpdateCaseProperty,
filterStatus,
@ -179,19 +177,17 @@ export const useCasesColumns = ({
name: i18n.NAME,
render: (theCase: Case | SubCase) => {
if (theCase.id != null && theCase.title != null) {
const caseDetailsLinkComponent =
caseDetailsNavigation != null ? (
<CaseDetailsLink
caseDetailsNavigation={caseDetailsNavigation}
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
title={theCase.title}
>
<TruncatedText text={theCase.title} />
</CaseDetailsLink>
) : (
const caseDetailsLinkComponent = isSelectorView ? (
<TruncatedText text={theCase.title} />
) : (
<CaseDetailsLink
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
title={theCase.title}
>
<TruncatedText text={theCase.title} />
);
</CaseDetailsLink>
);
return theCase.status !== CaseStatuses.closed ? (
caseDetailsLinkComponent
) : (

View file

@ -11,16 +11,12 @@ import styled, { css } from 'styled-components';
import { HeaderPage } from '../header_page';
import * as i18n from './translations';
import { Count } from './count';
import { CasesNavigation } from '../links';
import { ErrorMessage } from '../use_push_to_service/callout/types';
import { NavButtons } from './nav_buttons';
interface OwnProps {
actionsErrors: ErrorMessage[];
configureCasesNavigation: CasesNavigation;
createCaseNavigation: CasesNavigation;
refresh: number;
showTitle?: boolean;
userCanCrud: boolean;
}
@ -43,13 +39,10 @@ const FlexItemDivider = styled(EuiFlexItem)`
export const CasesTableHeader: FunctionComponent<Props> = ({
actionsErrors,
configureCasesNavigation,
createCaseNavigation,
refresh,
showTitle = true,
userCanCrud,
}) => (
<HeaderPage title={showTitle ? i18n.PAGE_TITLE : ''} border>
<HeaderPage title={i18n.PAGE_TITLE} border>
<EuiFlexGroup alignItems="center" gutterSize="m" wrap={true} data-test-subj="all-cases-header">
{userCanCrud ? (
<>
@ -58,11 +51,7 @@ export const CasesTableHeader: FunctionComponent<Props> = ({
</FlexItemDivider>
<EuiFlexItem>
<NavButtons
actionsErrors={actionsErrors}
configureCasesNavigation={configureCasesNavigation}
createCaseNavigation={createCaseNavigation}
/>
<NavButtons actionsErrors={actionsErrors} />
</EuiFlexItem>
</>
) : (

View file

@ -7,94 +7,49 @@
import React from 'react';
import { mount } from 'enzyme';
import moment from 'moment-timezone';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import '../../common/mock/match_media';
import { AllCases, AllCasesProps } from '.';
import { TestProviders } from '../../common/mock';
import {
casesStatus,
useGetCasesMockState,
collectionCase,
connectorsMock,
} from '../../containers/mock';
import { CaseStatuses, CaseType, SECURITY_SOLUTION_OWNER, StatusAll } from '../../../common';
import { getEmptyTagValue } from '../empty_value';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useKibana } from '../../common/lib/kibana';
import { AllCasesGeneric as AllCases } from './all_cases_generic';
import { AllCasesProps } from '.';
import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { CaseStatuses } from '../../../common';
import { casesStatus, connectorsMock, useGetCasesMockState } from '../../containers/mock';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
jest.mock('../../containers/use_bulk_update_case');
jest.mock('../../containers/use_delete_cases');
jest.mock('../../containers/use_get_cases');
jest.mock('../../containers/use_get_cases_status');
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/use_get_action_license');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/api');
jest.mock('../../common/lib/kibana');
jest.mock('../../containers/use_get_cases');
jest.mock('../../containers/use_get_cases_status');
const defaultAllCasesProps: AllCasesProps = {
disableAlerts: false,
};
const useDeleteCasesMock = useDeleteCases as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
const useGetActionLicenseMock = useGetActionLicense as jest.Mock;
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useConnectorsMock = useConnectors as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useGetActionLicenseMock = useGetActionLicense as jest.Mock;
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
describe('AllCases', () => {
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
triggersActionsUi: mockTriggersActionsUiService,
},
}),
};
});
describe('AllCasesGeneric', () => {
const defaultAllCasesProps: AllCasesProps = {
configureCasesNavigation: {
href: 'blah',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: jest.fn().mockReturnValue('testHref'), // string
onClick: jest.fn(),
},
createCaseNavigation: {
href: 'bleh',
onClick: jest.fn(),
},
userCanCrud: true,
owner: [SECURITY_SOLUTION_OWNER],
};
const dispatchResetIsDeleted = jest.fn();
const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const refetchCases = jest.fn();
const setFilters = jest.fn();
const setQueryParams = jest.fn();
const setSelectedCases = jest.fn();
const updateBulkStatus = jest.fn();
const fetchCasesStatus = jest.fn();
const onRowClick = jest.fn();
const emptyTag = getEmptyTagValue().props.children;
const defaultGetCases = {
...useGetCasesMockState,
@ -105,15 +60,6 @@ describe('AllCasesGeneric', () => {
setSelectedCases,
};
const defaultDeleteCases = {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isDeleted: false,
isDisplayConfirmDeleteModal: false,
isLoading: false,
};
const defaultCasesStatus = {
...casesStatus,
fetchCasesStatus,
@ -121,86 +67,34 @@ describe('AllCasesGeneric', () => {
isLoading: false,
};
const defaultUpdateCases = {
isUpdated: false,
isLoading: false,
isError: false,
dispatchResetIsUpdated,
updateBulkStatus,
};
const defaultActionLicense = {
actionLicense: null,
isLoading: false,
isError: false,
};
const defaultColumnArgs = {
caseDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
dispatchUpdateCaseProperty: jest.fn,
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
isLoadingCases: [],
isSelectorView: false,
userCanCrud: true,
};
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
(useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() });
(useGetReporters as jest.Mock).mockReturnValue({
reporters: ['casetester'],
respReporters: [{ username: 'casetester' }],
isLoading: true,
isError: false,
fetchReporters: jest.fn(),
});
(useGetActionLicense as jest.Mock).mockReturnValue({
actionLicense: null,
isLoading: false,
});
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useGetCasesStatusMock.mockReturnValue(defaultCasesStatus);
useGetActionLicenseMock.mockReturnValue(defaultActionLicense);
useGetCasesMock.mockReturnValue(defaultGetCases);
});
beforeEach(() => {
jest.clearAllMocks();
useUpdateCasesMock.mockReturnValue(defaultUpdateCases);
useGetCasesMock.mockReturnValue(defaultGetCases);
useDeleteCasesMock.mockReturnValue(defaultDeleteCases);
useGetCasesStatusMock.mockReturnValue(defaultCasesStatus);
useGetActionLicenseMock.mockReturnValue(defaultActionLicense);
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
moment.tz.setDefault('UTC');
});
it('should render AllCases', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().prop('href')).toEqual(
`testHref`
);
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().text()).toEqual(
useGetCasesMockState.data.cases[0].title
);
expect(
wrapper.find(`span[data-test-subj="case-table-column-tags-0"]`).first().prop('title')
).toEqual(useGetCasesMockState.data.cases[0].tags[0]);
expect(wrapper.find(`[data-test-subj="case-table-column-createdBy"]`).first().text()).toEqual(
useGetCasesMockState.data.cases[0].createdBy.fullName
);
expect(
wrapper
.find(`[data-test-subj="case-table-column-createdAt"]`)
.first()
.childAt(0)
.prop('value')
).toBe(useGetCasesMockState.data.cases[0].createdAt);
expect(wrapper.find(`[data-test-subj="case-table-case-count"]`).first().text()).toEqual(
'Showing 10 cases'
);
});
});
it('should render the stats', async () => {
@ -264,618 +158,6 @@ describe('AllCasesGeneric', () => {
});
});
it('should render empty fields', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
data: {
...defaultGetCases.data,
cases: [
{
...defaultGetCases.data.cases[0],
id: null,
createdAt: null,
createdBy: null,
status: null,
subCases: null,
tags: null,
title: null,
totalComment: null,
totalAlerts: null,
},
],
},
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
const checkIt = (columnName: string, key: number) => {
const column = wrapper.find('[data-test-subj="cases-table"] tbody .euiTableRowCell').at(key);
expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName);
expect(column.find('span').text()).toEqual(emptyTag);
};
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns(defaultColumnArgs)
);
await waitFor(() => {
result.current.map(
(i, key) =>
i.name != null &&
!Object.prototype.hasOwnProperty.call(i, 'actions') &&
checkIt(`${i.name}`, key)
);
});
});
it('should render delete actions for case', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toBeFalsy();
});
});
it.skip('should enable correct actions for sub cases', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
cases: [
{
...defaultGetCases.data.cases[0],
id: 'my-case-with-subcases',
createdAt: null,
createdBy: null,
status: null,
subCases: [
{
id: 'sub-case-id',
},
],
tags: null,
title: null,
totalComment: null,
totalAlerts: null,
type: CaseType.collection,
},
],
},
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toEqual(
false
);
});
it('should not render case link when caseDetailsNavigation is not passed or actions on showActions=false', async () => {
const { caseDetailsNavigation, ...rest } = defaultAllCasesProps;
const wrapper = mount(
<TestProviders>
<AllCases {...rest} />
</TestProviders>
);
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns({
dispatchUpdateCaseProperty: jest.fn,
isLoadingCases: [],
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
isSelectorView: true,
userCanCrud: true,
})
);
await waitFor(() => {
result.current.map(
(i) => i.name != null && !Object.prototype.hasOwnProperty.call(i, 'actions')
);
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy();
});
});
it('should tableHeaderSortButton AllCases', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="tableHeaderSortButton"]').first().simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
page: 1,
perPage: 5,
sortField: 'createdAt',
sortOrder: 'asc',
});
});
});
it('Updates status when status context menu is updated', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).first().simulate('click');
wrapper
.find(`[data-test-subj="case-view-status-dropdown-closed"] button`)
.first()
.simulate('click');
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.closed,
version: firstCase.version,
})
);
});
});
it('Bulk delete', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed },
selectedCases: [...useGetCasesMockState.data.cases, collectionCase],
});
useDeleteCasesMock
.mockReturnValueOnce({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: false,
})
.mockReturnValue({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().simulate('click');
wrapper
.find(
'[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]'
)
.last()
.simulate('click');
await waitFor(() => {
expect(handleToggleModal).toBeCalled();
expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual([
...useGetCasesMockState.data.cases.map(({ id, type, title }) => ({ id, type, title })),
{
id: collectionCase.id,
title: collectionCase.title,
type: collectionCase.type,
},
]);
});
});
it('Renders only bulk delete on status all', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: StatusAll },
selectedCases: [...useGetCasesMockState.data.cases],
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').exists()).toEqual(
false
);
expect(wrapper.find('[data-test-subj="cases-bulk-close-button"]').exists()).toEqual(false);
expect(
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled
).toEqual(false);
});
});
it('Renders correct bulk actions for case collection when filter status is set to all - enable only bulk delete if any collection is selected', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: [
...useGetCasesMockState.data.cases,
{
...useGetCasesMockState.data.cases[0],
type: CaseType.collection,
},
],
});
useDeleteCasesMock
.mockReturnValueOnce({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: false,
})
.mockReturnValue({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false);
expect(
wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().props().disabled
).toEqual(true);
expect(
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().props().disabled
).toEqual(true);
expect(
wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled
).toEqual(false);
});
});
it('Bulk close status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: useGetCasesMockState.data.cases,
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed);
});
});
it('Bulk open status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
selectedCases: useGetCasesMockState.data.cases,
filterOptions: {
...defaultGetCases.filterOptions,
status: CaseStatuses.closed,
},
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open);
});
});
it('Bulk in-progress status update', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
selectedCases: useGetCasesMockState.data.cases,
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().simulate('click');
await waitFor(() => {
expect(updateBulkStatus).toBeCalledWith(
useGetCasesMockState.data.cases,
CaseStatuses['in-progress']
);
});
});
it('isDeleted is true, refetch', async () => {
useDeleteCasesMock.mockReturnValue({
...defaultDeleteCases,
isDeleted: true,
});
mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
expect(refetchCases).toBeCalled();
expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsDeleted).toBeCalled();
});
});
it('isUpdated is true, refetch', async () => {
useUpdateCasesMock.mockReturnValue({
...defaultUpdateCases,
isUpdated: true,
});
mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
expect(refetchCases).toBeCalled();
expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsUpdated).toBeCalled();
});
});
it('should not render header when configureCasesNavigation are not present', async () => {
const { configureCasesNavigation, ...restProps } = defaultAllCasesProps;
const wrapper = mount(
<TestProviders>
<AllCases {...restProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="all-cases-header"]').exists()).toBe(false);
});
});
it('should not render table utility bar when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-table-selected-case-count"]').exists()).toBe(
false
);
expect(wrapper.find('[data-test-subj="case-table-bulk-actions"]').exists()).toBe(false);
});
});
it('case table should not be selectable when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-table"]').first().prop('isSelectable')).toBe(
false
);
});
});
it('should call onRowClick with no cases and isSelectorView=true', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
total: 0,
cases: [],
},
});
const wrapper = mount(
<TestProviders>
<AllCases
{...defaultAllCasesProps}
userCanCrud={true}
isSelectorView={true}
onRowClick={onRowClick}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).toHaveBeenCalled();
});
});
it('should call createCaseNavigation.onClick with no cases and isSelectorView=false', async () => {
const createCaseNavigation = { href: '', onClick: jest.fn() };
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
total: 0,
cases: [],
},
});
const wrapper = mount(
<TestProviders>
<AllCases
{...defaultAllCasesProps}
createCaseNavigation={createCaseNavigation}
isSelectorView={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click');
await waitFor(() => {
expect(createCaseNavigation.onClick).toHaveBeenCalled();
});
});
it('should call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases
{...defaultAllCasesProps}
userCanCrud={true}
isSelectorView={true}
onRowClick={onRowClick}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).toHaveBeenCalledWith({
closedAt: null,
closedBy: null,
comments: [],
connector: { fields: null, id: '123', name: 'My Connector', type: '.jira' },
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
description: 'Security banana Issue',
externalService: {
connectorId: '123',
connectorName: 'connector name',
externalId: 'external_id',
externalTitle: 'external title',
externalUrl: 'basicPush.com',
pushedAt: '2020-02-20T15:02:57.995Z',
pushedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
},
id: '1',
owner: SECURITY_SOLUTION_OWNER,
status: 'open',
subCaseIds: [],
tags: ['coke', 'pepsi'],
title: 'Another horrible breach!!',
totalAlerts: 0,
totalComment: 0,
type: CaseType.individual,
updatedAt: '2020-02-20T15:02:57.995Z',
updatedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
version: 'WzQ3LDFd',
settings: {
syncAlerts: true,
},
});
});
});
it('should NOT call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click');
await waitFor(() => {
expect(onRowClick).not.toHaveBeenCalled();
});
});
it('should change the status to closed', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'closedAt',
});
});
});
it('should change the status to in-progress', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'createdAt',
});
});
});
it('should change the status to open', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click');
await waitFor(() => {
expect(setQueryParams).toBeCalledWith({
sortField: 'createdAt',
});
});
});
it('should show the correct count on stats', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
await waitFor(() => {
expect(wrapper.find('button[data-test-subj="case-status-filter-open"]').text()).toBe(
'Open (20)'
);
expect(wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').text()).toBe(
'In progress (40)'
);
expect(wrapper.find('button[data-test-subj="case-status-filter-closed"]').text()).toBe(
'Closed (130)'
);
});
});
it('should not allow the user to enter configuration page with basic license', async () => {
useGetActionLicenseMock.mockReturnValue({
...defaultActionLicense,
@ -927,27 +209,4 @@ describe('AllCasesGeneric', () => {
).toBeFalsy();
});
});
it('should not render status when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} isSelectorView={true} />
</TestProviders>
);
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns({
...defaultColumnArgs,
isSelectorView: true,
})
);
expect(result.current.find((i) => i.name === 'Status')).toBeFalsy();
await waitFor(() => {
expect(wrapper.find('[data-test-subj="cases-table"]').exists()).toBeTruthy();
});
expect(wrapper.find('[data-test-subj="case-view-status-dropdown"]').exists()).toBeFalsy();
});
});

View file

@ -5,25 +5,38 @@
* 2.0.
*/
import React from 'react';
import { Owner } from '../../types';
import { CaseDetailsHrefSchema, CasesNavigation } from '../links';
import { OwnerProvider } from '../owner_context';
import { AllCasesGeneric } from './all_cases_generic';
export interface AllCasesProps extends Owner {
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; // if not passed, case name is not displayed as a link (Formerly dependant on isSelector)
configureCasesNavigation: CasesNavigation; // if not passed, header with nav is not displayed (Formerly dependant on isSelector)
createCaseNavigation: CasesNavigation;
import React, { useCallback, useState, useMemo } from 'react';
import { CasesDeepLinkId } from '../../common/navigation';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCasesBreadcrumbs } from '../use_breadcrumbs';
import { getActionLicenseError } from '../use_push_to_service/helpers';
import { AllCasesList } from './all_cases_list';
import { CasesTableHeader } from './header';
export interface AllCasesProps {
disableAlerts?: boolean;
showTitle?: boolean;
userCanCrud: boolean;
}
export const AllCases: React.FC<AllCasesProps> = (props) => (
<OwnerProvider owner={props.owner}>
<AllCasesGeneric {...props} />
</OwnerProvider>
);
export const AllCases: React.FC<AllCasesProps> = ({ disableAlerts }) => {
const { userCanCrud } = useCasesContext();
const [refresh, setRefresh] = useState<number>(0);
useCasesBreadcrumbs(CasesDeepLinkId.cases);
const doRefresh = useCallback(() => {
setRefresh((prev) => prev + 1);
}, [setRefresh]);
const { actionLicense } = useGetActionLicense();
const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]);
return (
<>
<CasesTableHeader actionsErrors={actionsErrors} refresh={refresh} userCanCrud={userCanCrud} />
<AllCasesList disableAlerts={disableAlerts} doRefresh={doRefresh} />
</>
);
};
// eslint-disable-next-line import/no-default-export
export { AllCases as default };

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import React, { FunctionComponent, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import styled, { css } from 'styled-components';
import { ConfigureCaseButton } from '../configure_cases/button';
import * as i18n from './translations';
import { CasesNavigation, LinkButton } from '../links';
import { ConfigureCaseButton, LinkButton } from '../links';
import { ErrorMessage } from '../use_push_to_service/callout/types';
import { useCreateCaseNavigation } from '../../common/navigation';
const ButtonFlexGroup = styled(EuiFlexGroup)`
${({ theme }) => css`
@ -26,38 +26,41 @@ const ButtonFlexGroup = styled(EuiFlexGroup)`
interface OwnProps {
actionsErrors: ErrorMessage[];
configureCasesNavigation: CasesNavigation;
createCaseNavigation: CasesNavigation;
}
type Props = OwnProps;
export const NavButtons: FunctionComponent<Props> = ({
actionsErrors,
configureCasesNavigation,
createCaseNavigation,
}) => (
<ButtonFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<ConfigureCaseButton
configureCasesNavigation={configureCasesNavigation}
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? <>{actionsErrors[0].description}</> : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
/>
</EuiFlexItem>
<EuiFlexItem>
<LinkButton
fill
onClick={createCaseNavigation.onClick}
href={createCaseNavigation.href}
iconType="plusInCircle"
data-test-subj="createNewCaseBtn"
>
{i18n.CREATE_TITLE}
</LinkButton>
</EuiFlexItem>
</ButtonFlexGroup>
);
export const NavButtons: FunctionComponent<Props> = ({ actionsErrors }) => {
const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation();
const navigateToCreateCaseClick = useCallback(
(e) => {
e.preventDefault();
navigateToCreateCase();
},
[navigateToCreateCase]
);
return (
<ButtonFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<ConfigureCaseButton
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? <>{actionsErrors[0].description}</> : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
/>
</EuiFlexItem>
<EuiFlexItem>
<LinkButton
fill
onClick={navigateToCreateCaseClick}
href={getCreateCaseUrl()}
iconType="plusInCircle"
data-test-subj="createNewCaseBtn"
>
{i18n.CREATE_TITLE}
</LinkButton>
</EuiFlexItem>
</ButtonFlexGroup>
);
};

View file

@ -10,24 +10,21 @@ import { mount } from 'enzyme';
import { AllCasesSelectorModal } from '.';
import { TestProviders } from '../../../common/mock';
import { AllCasesGeneric } from '../all_cases_generic';
import { AllCasesList } from '../all_cases_list';
import { SECURITY_SOLUTION_OWNER } from '../../../../common';
jest.mock('../../../methods');
jest.mock('../all_cases_generic');
jest.mock('../all_cases_list');
const onRowClick = jest.fn();
const createCaseNavigation = { href: '', onClick: jest.fn() };
const defaultProps = {
createCaseNavigation,
onRowClick,
userCanCrud: true,
owner: [SECURITY_SOLUTION_OWNER],
};
const updateCase = jest.fn();
describe('AllCasesSelectorModal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('renders', () => {
@ -66,19 +63,19 @@ describe('AllCasesSelectorModal', () => {
hiddenStatuses: [],
updateCase,
};
mount(
<TestProviders>
<AllCasesSelectorModal {...fullProps} />
</TestProviders>
);
// @ts-ignore idk what this mock style is but it works ¯\_(ツ)_/¯
expect(AllCasesGeneric.type.mock.calls[0][0]).toEqual(
expect(AllCasesList.type.mock.calls[0][0]).toEqual(
expect.objectContaining({
alertData: fullProps.alertData,
createCaseNavigation,
hiddenStatuses: fullProps.hiddenStatuses,
isSelectorView: true,
userCanCrud: fullProps.userCanCrud,
updateCase,
})
);

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useCallback } from 'react';
import {
EuiButton,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import {
Case,
CaseStatusWithAllStatus,
CommentRequestAlertType,
SubCase,
} from '../../../../common';
import * as i18n from '../../../common/translations';
import { AllCasesList } from '../all_cases_list';
export interface AllCasesSelectorModalProps {
alertData?: Omit<CommentRequestAlertType, 'type'>;
hiddenStatuses?: CaseStatusWithAllStatus[];
onRowClick: (theCase?: Case | SubCase) => void;
updateCase?: (newCase: Case) => void;
onClose?: () => void;
}
const Modal = styled(EuiModal)`
${({ theme }) => `
width: ${theme.eui.euiBreakpoints.l};
max-width: ${theme.eui.euiBreakpoints.l};
`}
`;
export const AllCasesSelectorModal = React.memo<AllCasesSelectorModalProps>(
({ alertData, hiddenStatuses, onRowClick, updateCase, onClose }) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const closeModal = useCallback(() => {
if (onClose) {
onClose();
}
setIsModalOpen(false);
}, [onClose]);
const onClick = useCallback(
(theCase?: Case | SubCase) => {
closeModal();
onRowClick(theCase);
},
[closeModal, onRowClick]
);
return isModalOpen ? (
<Modal onClose={closeModal} data-test-subj="all-cases-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.SELECT_CASE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<AllCasesList
alertData={alertData}
hiddenStatuses={hiddenStatuses}
isSelectorView={true}
onRowClick={onClick}
updateCase={updateCase}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton color="text" onClick={closeModal}>
{i18n.CANCEL}
</EuiButton>
</EuiModalFooter>
</Modal>
) : null;
}
);
AllCasesSelectorModal.displayName = 'AllCasesSelectorModal';

View file

@ -4,103 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AllCasesSelectorModal, AllCasesSelectorModalProps } from './all_cases_selector_modal';
import React, { useState, useCallback } from 'react';
import {
EuiButton,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import {
Case,
CaseStatusWithAllStatus,
CommentRequestAlertType,
SubCase,
} from '../../../../common';
import { CasesNavigation } from '../../links';
import * as i18n from '../../../common/translations';
import { AllCasesGeneric } from '../all_cases_generic';
import { Owner } from '../../../types';
import { OwnerProvider } from '../../owner_context';
export interface AllCasesSelectorModalProps extends Owner {
alertData?: Omit<CommentRequestAlertType, 'type'>;
createCaseNavigation: CasesNavigation;
hiddenStatuses?: CaseStatusWithAllStatus[];
onRowClick: (theCase?: Case | SubCase) => void;
updateCase?: (newCase: Case) => void;
userCanCrud: boolean;
onClose?: () => void;
}
const Modal = styled(EuiModal)`
${({ theme }) => `
width: ${theme.eui.euiBreakpoints.l};
max-width: ${theme.eui.euiBreakpoints.l};
`}
`;
const AllCasesSelectorModalComponent: React.FC<AllCasesSelectorModalProps> = ({
alertData,
createCaseNavigation,
hiddenStatuses,
onRowClick,
updateCase,
userCanCrud,
onClose,
}) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const closeModal = useCallback(() => {
if (onClose) {
onClose();
}
setIsModalOpen(false);
}, [onClose]);
const onClick = useCallback(
(theCase?: Case | SubCase) => {
closeModal();
onRowClick(theCase);
},
[closeModal, onRowClick]
);
return isModalOpen ? (
<Modal onClose={closeModal} data-test-subj="all-cases-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.SELECT_CASE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<AllCasesGeneric
alertData={alertData}
createCaseNavigation={createCaseNavigation}
hiddenStatuses={hiddenStatuses}
isSelectorView={true}
onRowClick={onClick}
userCanCrud={userCanCrud}
updateCase={updateCase}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton color="text" onClick={closeModal}>
{i18n.CANCEL}
</EuiButton>
</EuiModalFooter>
</Modal>
) : null;
};
export const AllCasesSelectorModal: React.FC<AllCasesSelectorModalProps> = React.memo((props) => {
return (
<OwnerProvider owner={props.owner}>
<AllCasesSelectorModalComponent {...props} />
</OwnerProvider>
);
});
AllCasesSelectorModal.displayName = 'AllCasesSelectorModal';
export type { AllCasesSelectorModalProps };
export { AllCasesSelectorModal };
// eslint-disable-next-line import/no-default-export
export { AllCasesSelectorModal as default };

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FunctionComponent, MutableRefObject } from 'react';
import React, { FunctionComponent, MutableRefObject, useCallback } from 'react';
import {
EuiEmptyPrompt,
EuiLoadingContent,
@ -17,16 +17,16 @@ import classnames from 'classnames';
import styled from 'styled-components';
import { CasesTableUtilityBar } from './utility_bar';
import { CasesNavigation, LinkButton } from '../links';
import { LinkButton } from '../links';
import { AllCases, Case, FilterOptions } from '../../../common';
import * as i18n from './translations';
import { useCreateCaseNavigation } from '../../common/navigation';
interface CasesTableProps {
columns: EuiBasicTableProps<Case>['columns']; // CasesColumns[];
createCaseNavigation: CasesNavigation;
data: AllCases;
filterOptions: FilterOptions;
goToCreateCase: (e: React.MouseEvent) => void;
goToCreateCase?: () => void;
handleIsLoading: (a: boolean) => void;
isCasesLoading: boolean;
isCommentUpdating: boolean;
@ -76,7 +76,6 @@ const Div = styled.div`
export const CasesTable: FunctionComponent<CasesTableProps> = ({
columns,
createCaseNavigation,
data,
filterOptions,
goToCreateCase,
@ -96,8 +95,21 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
tableRef,
tableRowProps,
userCanCrud,
}) =>
isCasesLoading && isDataEmpty ? (
}) => {
const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation();
const navigateToCreateCaseClick = useCallback(
(ev) => {
ev.preventDefault();
if (goToCreateCase != null) {
goToCreateCase();
} else {
navigateToCreateCase();
}
},
[goToCreateCase, navigateToCreateCase]
);
return isCasesLoading && isDataEmpty ? (
<Div>
<EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} />
</Div>
@ -131,8 +143,8 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
isDisabled={!userCanCrud}
fill
size="s"
onClick={goToCreateCase}
href={createCaseNavigation.href}
onClick={navigateToCreateCaseClick}
href={getCreateCaseUrl()}
iconType="plusInCircle"
data-test-subj="cases-table-add-case"
>
@ -151,3 +163,4 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
/>
</Div>
);
};

View file

@ -34,7 +34,7 @@ const props = {
describe('CasesTableFilters ', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
(useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags });
(useGetReporters as jest.Mock).mockReturnValue({
reporters: ['casetester'],

View file

@ -141,7 +141,12 @@ export const CasesTableUtilityBar: FunctionComponent<Props> = ({
</UtilityBarAction>
</>
)}
<UtilityBarAction iconSide="left" iconType="refresh" onClick={refreshCases}>
<UtilityBarAction
iconSide="left"
iconType="refresh"
onClick={refreshCases}
dataTestSubj="all-cases-refresh"
>
{i18n.REFRESH}
</UtilityBarAction>
</UtilityBarGroup>

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import md5 from 'md5';
import { CasesRoutes } from './routes';
import { CasesRoutesProps } from './types';
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));
export type CasesProps = CasesRoutesProps;
// eslint-disable-next-line import/no-default-export
export { CasesRoutes as default };

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import { MemoryRouterProps } from 'react-router';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TestProviders } from '../../common/mock';
import { CasesRoutes } from './routes';
jest.mock('../all_cases', () => ({
AllCases: () => <div>{'All cases'}</div>,
}));
jest.mock('../case_view', () => ({
CaseView: () => <div>{'Case view'}</div>,
}));
jest.mock('../create', () => ({
CreateCase: () => <div>{'Create case'}</div>,
}));
jest.mock('../configure_cases', () => ({
ConfigureCases: () => <div>{'Configure cases'}</div>,
}));
const getCaseViewPaths = () => [
'/cases/test-id',
'/cases/test-id/comment-id',
'/cases/test-id/sub-cases/sub-case-id',
'/cases/test-id/sub-cases/sub-case-id/comment-id',
];
const renderWithRouter = (
initialEntries: MemoryRouterProps['initialEntries'] = ['/cases'],
userCanCrud = true
) => {
return render(
<TestProviders userCanCrud={userCanCrud}>
<MemoryRouter initialEntries={initialEntries}>
<CasesRoutes useFetchAlertData={(alertIds) => [false, {}]} />
</MemoryRouter>
</TestProviders>
);
};
describe('Cases routes', () => {
describe('All cases', () => {
it('navigates to the all cases page', () => {
renderWithRouter();
expect(screen.getByText('All cases')).toBeInTheDocument();
});
// User has read only privileges
it('user can navigate to the all cases page with userCanCrud = false', () => {
renderWithRouter(['/cases'], false);
expect(screen.getByText('All cases')).toBeInTheDocument();
});
});
describe('Case view', () => {
it.each(getCaseViewPaths())('navigates to the cases view page for path: %s', (path: string) => {
renderWithRouter([path]);
expect(screen.getByText('Case view')).toBeInTheDocument();
// User has read only privileges
});
it.each(getCaseViewPaths())(
'user can navigate to the cases view page with userCanCrud = false and path: %s',
(path: string) => {
renderWithRouter([path], false);
expect(screen.getByText('Case view')).toBeInTheDocument();
}
);
});
describe('Create case', () => {
it('navigates to the create case page', () => {
renderWithRouter(['/cases/create']);
expect(screen.getByText('Create case')).toBeInTheDocument();
});
it('shows the no privileges page if userCanCrud = false', () => {
renderWithRouter(['/cases/create'], false);
expect(screen.getByText('Privileges required')).toBeInTheDocument();
});
});
describe('Configure cases', () => {
it('navigates to the configure cases page', () => {
renderWithRouter(['/cases/configure']);
expect(screen.getByText('Configure cases')).toBeInTheDocument();
});
it('shows the no privileges page if userCanCrud = false', () => {
renderWithRouter(['/cases/configure'], false);
expect(screen.getByText('Privileges required')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { AllCases } from '../all_cases';
import { CaseView } from '../case_view';
import { CreateCase } from '../create';
import { ConfigureCases } from '../configure_cases';
import { CasesRoutesProps } from './types';
import { useCasesContext } from '../cases_context/use_cases_context';
import {
getCasesConfigurePath,
getCreateCasePath,
getCaseViewPath,
getCaseViewWithCommentPath,
getSubCaseViewPath,
getSubCaseViewWithCommentPath,
useAllCasesNavigation,
useCaseViewNavigation,
} from '../../common/navigation';
import { NoPrivilegesPage } from '../no_privileges';
import * as i18n from './translations';
import { useReadonlyHeader } from './use_readonly_header';
const CasesRoutesComponent: React.FC<CasesRoutesProps> = ({
disableAlerts,
onComponentInitialized,
actionsNavigation,
ruleDetailsNavigation,
showAlertDetails,
useFetchAlertData,
refreshRef,
hideSyncAlerts,
timelineIntegration,
}) => {
const { basePath, userCanCrud } = useCasesContext();
const { navigateToAllCases } = useAllCasesNavigation();
const { navigateToCaseView } = useCaseViewNavigation();
useReadonlyHeader();
const onCreateCaseSuccess = useCallback(
async ({ id }) => navigateToCaseView({ detailName: id }),
[navigateToCaseView]
);
return (
<Switch>
<Route strict exact path={basePath}>
<AllCases disableAlerts={disableAlerts} />
</Route>
<Route path={getCreateCasePath(basePath)}>
{userCanCrud ? (
<CreateCase
onSuccess={onCreateCaseSuccess}
onCancel={navigateToAllCases}
disableAlerts={disableAlerts}
timelineIntegration={timelineIntegration}
/>
) : (
<NoPrivilegesPage pageName={i18n.CREATE_CASE_PAGE_NAME} />
)}
</Route>
<Route path={getCasesConfigurePath(basePath)}>
{userCanCrud ? (
<ConfigureCases />
) : (
<NoPrivilegesPage pageName={i18n.CONFIGURE_CASES_PAGE_NAME} />
)}
</Route>
<Route
exact
path={[
getSubCaseViewWithCommentPath(basePath),
getCaseViewWithCommentPath(basePath),
getSubCaseViewPath(basePath),
getCaseViewPath(basePath),
]}
>
<CaseView
onComponentInitialized={onComponentInitialized}
actionsNavigation={actionsNavigation}
ruleDetailsNavigation={ruleDetailsNavigation}
showAlertDetails={showAlertDetails}
useFetchAlertData={useFetchAlertData}
refreshRef={refreshRef}
hideSyncAlerts={hideSyncAlerts}
timelineIntegration={timelineIntegration}
/>
</Route>
<Route path={basePath}>
<Redirect to={basePath} />
</Route>
</Switch>
);
};
export const CasesRoutes = React.memo(CasesRoutesComponent);

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const NO_PRIVILEGES_MSG = (pageName: string) =>
i18n.translate('xpack.cases.noPrivileges.message', {
values: { pageName },
defaultMessage:
'To view {pageName} page, you must update privileges. For more information, contact your Kibana administrator.',
});
export const NO_PRIVILEGES_TITLE = i18n.translate('xpack.cases.noPrivileges.title', {
defaultMessage: 'Privileges required',
});
export const NO_PRIVILEGES_BUTTON = i18n.translate('xpack.cases.noPrivileges.button', {
defaultMessage: 'Back to Cases',
});
export const CREATE_CASE_PAGE_NAME = i18n.translate('xpack.cases.createCase', {
defaultMessage: 'Create Case',
});
export const CONFIGURE_CASES_PAGE_NAME = i18n.translate('xpack.cases.configureCases', {
defaultMessage: 'Configure Cases',
});
export const READ_ONLY_BADGE_TEXT = i18n.translate('xpack.cases.badge.readOnly.text', {
defaultMessage: 'Read only',
});
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate('xpack.cases.badge.readOnly.tooltip', {
defaultMessage: 'Unable to create or edit cases',
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MutableRefObject } from 'react';
import { Ecs, CaseViewRefreshPropInterface } from '../../../common';
import { CasesNavigation } from '../links';
import { CasesTimelineIntegration } from '../timeline_context';
export interface CasesRoutesProps {
disableAlerts?: boolean;
onComponentInitialized?: () => void;
actionsNavigation?: CasesNavigation<string, 'configurable'>;
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
showAlertDetails?: (alertId: string, index: string) => void;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
*/
refreshRef?: MutableRefObject<CaseViewRefreshPropInterface>;
hideSyncAlerts?: boolean;
timelineIntegration?: CasesTimelineIntegration;
}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import { useReadonlyHeader } from './use_readonly_header';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../common/lib/kibana');
const mockedSetBadge = jest.fn();
describe('CaseContainerComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.chrome.setBadge = mockedSetBadge;
});
it('does not display the readonly glasses badge when the user has write permissions', () => {
renderHook(() => useReadonlyHeader(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(mockedSetBadge).not.toBeCalled();
});
it('displays the readonly glasses badge read permissions but not write', () => {
renderHook(() => useReadonlyHeader(), {
wrapper: ({ children }) => <TestProviders userCanCrud={false}>{children}</TestProviders>,
});
expect(mockedSetBadge).toBeCalledTimes(1);
});
});

View file

@ -7,27 +7,27 @@
import { useCallback, useEffect } from 'react';
import * as i18n from '../components/app/cases/translations';
import { useGetUserCasesPermissions } from '../hooks/use_get_user_cases_permissions';
import { useKibana } from '../utils/kibana_react';
import * as i18n from './translations';
import { useKibana } from '../../common/lib/kibana';
import { useCasesContext } from '../cases_context/use_cases_context';
/**
* This component places a read-only icon badge in the header if user only has read permissions
*/
export function useReadonlyHeader() {
const userPermissions = useGetUserCasesPermissions();
const { userCanCrud } = useCasesContext();
const chrome = useKibana().services.chrome;
// if the user is read only then display the glasses badge in the global navigation header
const setBadge = useCallback(() => {
if (userPermissions != null && !userPermissions.crud && userPermissions.read) {
if (!userCanCrud) {
chrome.setBadge({
text: i18n.READ_ONLY_BADGE_TEXT,
tooltip: i18n.READ_ONLY_BADGE_TOOLTIP,
iconType: 'glasses',
});
}
}, [chrome, userPermissions]);
}, [chrome, userCanCrud]);
useEffect(() => {
setBadge();

View file

@ -13,22 +13,18 @@ import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { PropertyActions } from '../property_actions';
import { Case } from '../../../common';
import { CaseService } from '../../containers/use_get_case_user_actions';
import { CasesNavigation } from '../links';
import { useAllCasesNavigation } from '../../common/navigation';
interface CaseViewActions {
allCasesNavigation: CasesNavigation;
caseData: Case;
currentExternalIncident: CaseService | null;
}
const ActionsComponent: React.FC<CaseViewActions> = ({
allCasesNavigation,
caseData,
currentExternalIncident,
}) => {
const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternalIncident }) => {
// Delete case
const { handleToggleModal, handleOnDeleteConfirm, isDeleted, isDisplayConfirmDeleteModal } =
useDeleteCases();
const { navigateToAllCases } = useAllCasesNavigation();
const propertyActions = useMemo(
() => [
@ -51,7 +47,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
);
if (isDeleted) {
allCasesNavigation.onClick(null);
navigateToAllCases();
return null;
}
return (

View file

@ -25,7 +25,6 @@ import { StatusContextMenu } from './status_context_menu';
import { getStatusDate, getStatusTitle } from './helpers';
import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch';
import { OnUpdateFields } from '../case_view';
import { CasesNavigation } from '../links';
const MyDescriptionList = styled(EuiDescriptionList)`
${({ theme }) => css`
@ -41,7 +40,6 @@ const MyDescriptionList = styled(EuiDescriptionList)`
`;
interface CaseActionBarProps {
allCasesNavigation: CasesNavigation;
caseData: Case;
currentExternalIncident: CaseService | null;
userCanCrud: boolean;
@ -51,7 +49,6 @@ interface CaseActionBarProps {
onUpdateField: (args: OnUpdateFields) => void;
}
const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
allCasesNavigation,
caseData,
currentExternalIncident,
disableAlerting,
@ -157,11 +154,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
</EuiFlexItem>
{userCanCrud && (
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<Actions
allCasesNavigation={allCasesNavigation}
caseData={caseData}
currentExternalIncident={currentExternalIncident}
/>
<Actions caseData={caseData} currentExternalIncident={currentExternalIncident} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -9,24 +9,29 @@ import React from 'react';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import * as i18n from './translations';
import { CasesNavigation } from '../links';
import { useAllCasesNavigation } from '../../common/navigation';
interface Props {
allCasesNavigation: CasesNavigation;
caseId: string;
}
export const DoesNotExist = ({ allCasesNavigation, caseId }: Props) => (
<EuiEmptyPrompt
iconColor="default"
iconType="addDataApp"
title={<h2>{i18n.DOES_NOT_EXIST_TITLE}</h2>}
titleSize="xs"
body={<p>{i18n.DOES_NOT_EXIST_DESCRIPTION(caseId)}</p>}
actions={
<EuiButton onClick={allCasesNavigation.onClick} size="s" color="primary" fill>
{i18n.DOES_NOT_EXIST_BUTTON}
</EuiButton>
}
/>
);
export const DoesNotExist = React.memo(({ caseId }: Props) => {
const { navigateToAllCases } = useAllCasesNavigation();
return (
<EuiEmptyPrompt
iconColor="default"
iconType="addDataApp"
title={<h2>{i18n.DOES_NOT_EXIST_TITLE}</h2>}
titleSize="xs"
body={<p>{i18n.DOES_NOT_EXIST_DESCRIPTION(caseId)}</p>}
actions={
<EuiButton onClick={navigateToAllCases} size="s" color="primary" fill>
{i18n.DOES_NOT_EXIST_BUTTON}
</EuiButton>
}
/>
);
});
DoesNotExist.displayName = 'DoesNotExist';

View file

@ -7,6 +7,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import '../../common/mock/match_media';
import { CaseComponent, CaseComponentProps, CaseView, CaseViewProps } from '.';
@ -22,7 +23,6 @@ import { SpacesApi } from '../../../../spaces/public';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { waitFor } from '@testing-library/react';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
@ -30,10 +30,6 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service'
import { CaseType, ConnectorTypes } from '../../../common';
import { useKibana } from '../../common/lib/kibana';
const mockId = basicCase.id;
jest.mock('react-router-dom', () => ({
useParams: () => ({ detailName: mockId }),
}));
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
@ -41,13 +37,13 @@ jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../user_action_tree/user_action_timestamp');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const onCaseDataSuccessMock = jest.fn();
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const spacesUiApiMock = {
redirectLegacyUrl: jest.fn().mockResolvedValue(undefined),
@ -84,20 +80,7 @@ const alertsHit = [
];
export const caseProps: CaseComponentProps = {
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: basicCase.id,
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
@ -115,7 +98,6 @@ export const caseProps: CaseComponentProps = {
'alert-id-2': alertsHit[1],
},
],
userCanCrud: true,
caseData: {
...basicCase,
comments: [...basicCase.comments, alertComment],
@ -128,7 +110,6 @@ export const caseProps: CaseComponentProps = {
},
fetchCase: jest.fn(),
updateCase: jest.fn(),
onCaseDataSuccess: onCaseDataSuccessMock,
};
export const caseClosedProps: CaseComponentProps = {
@ -373,15 +354,6 @@ describe('CaseView ', () => {
await waitFor(() => {
expect(updateObject.updateKey).toEqual('title');
expect(updateObject.updateValue).toEqual(newTitle);
expect(updateObject.onSuccess).toBeDefined();
});
updateObject.onSuccess(); // simulate the request has succeed
await waitFor(() => {
expect(onCaseDataSuccessMock).toHaveBeenCalledWith({
...caseProps.caseData,
title: newTitle,
});
});
});
@ -472,7 +444,7 @@ describe('CaseView ', () => {
expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled();
expect(spacesUiApiMock.redirectLegacyUrl).toHaveBeenCalledWith(
`cases/${resolveAliasId}`,
`/cases/${resolveAliasId}`,
'case'
);
});
@ -498,7 +470,7 @@ describe('CaseView ', () => {
objectNoun: 'case',
currentObjectId: defaultGetCase.data.id,
otherObjectId: resolveAliasId,
otherObjectPath: `cases/${resolveAliasId}`,
otherObjectPath: `/cases/${resolveAliasId}`,
});
});
});
@ -749,54 +721,41 @@ describe('CaseView ', () => {
describe('when a `refreshRef` prop is provided', () => {
let refreshRef: CaseViewProps['refreshRef'];
beforeEach(() => {
beforeEach(async () => {
(useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
refreshRef = React.createRef();
mount(
<TestProviders>
<CaseView
{...{
refreshRef,
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: '1234',
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
</TestProviders>
);
await act(async () => {
mount(
<TestProviders>
<CaseView
{...{
refreshRef,
caseId: '1234',
onComponentInitialized: jest.fn(),
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
</TestProviders>
);
});
});
it('should set it with expected refresh interface', async () => {
expect(refreshRef!.current).toEqual({
refreshUserActionsAndComments: expect.any(Function),
refreshCase: expect.any(Function),
await waitFor(() => {
expect(refreshRef!.current).toEqual({
refreshUserActionsAndComments: expect.any(Function),
refreshCase: expect.any(Function),
});
});
});
it('should refresh actions and comments', async () => {
await waitFor(() => {
refreshRef!.current!.refreshUserActionsAndComments();
expect(fetchCaseUserActions).toBeCalledWith('1234', 'resilient-2', undefined);
expect(fetchCaseUserActions).toBeCalledWith('basic-case-id', 'resilient-2', undefined);
expect(fetchCase).toBeCalledWith(true);
});
});

View file

@ -37,24 +37,25 @@ import * as i18n from './translations';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { CasesNavigation } from '../links';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';
import { useKibana } from '../../common/lib/kibana';
import { useCasesContext } from '../cases_context/use_cases_context';
import {
generateCaseViewPath,
useCaseViewNavigation,
useCaseViewParams,
} from '../../common/navigation';
import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation;
caseId: string;
configureCasesNavigation: CasesNavigation;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
subCaseId?: string;
onComponentInitialized?: () => void;
actionsNavigation?: CasesNavigation<string, 'configurable'>;
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
showAlertDetails?: (alertId: string, index: string) => void;
subCaseId?: string;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
@ -63,8 +64,7 @@ export interface CaseViewComponentProps {
hideSyncAlerts?: boolean;
}
export interface CaseViewProps extends CaseViewComponentProps {
onCaseDataSuccess?: (data: Case) => void;
export interface CaseViewProps extends Omit<CaseViewComponentProps, 'caseId' | 'subCaseId'> {
timelineIntegration?: CasesTimelineIntegration;
}
@ -83,19 +83,13 @@ export interface CaseComponentProps extends CaseViewComponentProps {
fetchCase: UseGetCase['fetchCase'];
caseData: Case;
updateCase: (newCase: Case) => void;
onCaseDataSuccess?: (newCase: Case) => void;
}
export const CaseComponent = React.memo<CaseComponentProps>(
({
allCasesNavigation,
caseData,
caseDetailsNavigation,
caseId,
configureCasesNavigation,
getCaseDetailHrefWithCommentId,
fetchCase,
onCaseDataSuccess,
onComponentInitialized,
actionsNavigation,
ruleDetailsNavigation,
@ -103,10 +97,13 @@ export const CaseComponent = React.memo<CaseComponentProps>(
subCaseId,
updateCase,
useFetchAlertData,
userCanCrud,
refreshRef,
hideSyncAlerts = false,
}) => {
const { userCanCrud } = useCasesContext();
const { getCaseViewUrl } = useCaseViewNavigation();
useCasesTitleBreadcrumbs(caseData.title);
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
const timelineUi = useTimelineContext()?.ui;
@ -321,13 +318,8 @@ export const CaseComponent = React.memo<CaseComponentProps>(
onUpdateField({
key: 'title',
value: newTitle,
onSuccess: () => {
if (onCaseDataSuccess) {
onCaseDataSuccess({ ...caseData, title: newTitle });
}
},
}),
[caseData, onUpdateField, onCaseDataSuccess]
[onUpdateField]
);
const changeStatus = useCallback(
@ -347,9 +339,9 @@ export const CaseComponent = React.memo<CaseComponentProps>(
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(caseDetailsNavigation.href),
body: i18n.EMAIL_BODY(getCaseViewUrl({ detailName: caseId, subCaseId })),
}),
[caseDetailsNavigation.href, caseData.title]
[caseData.title, getCaseViewUrl, caseId, subCaseId]
);
useEffect(() => {
@ -358,16 +350,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
}
}, [initLoadingData, isLoadingUserActions]);
const backOptions = useMemo(
() => ({
href: allCasesNavigation.href,
text: i18n.BACK_TO_ALL,
dataTestSubj: 'backToCases',
onClick: allCasesNavigation.onClick,
}),
[allCasesNavigation]
);
const onShowAlertDetails = useCallback(
(alertId: string, index: string) => {
if (showAlertDetails) {
@ -390,7 +372,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
return (
<>
<HeaderPage
backOptions={backOptions}
showBackButton={true}
data-test-subj="case-view-title"
titleNode={
<EditableTitle
@ -403,7 +385,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
title={caseData.title}
>
<CaseActionBar
allCasesNavigation={allCasesNavigation}
caseData={caseData}
currentExternalIncident={currentExternalIncident}
userCanCrud={userCanCrud}
@ -424,7 +405,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
{!initLoadingData && (
<>
<UserActionTree
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseServices={caseServices}
@ -485,7 +465,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
<EditConnector
caseData={caseData}
caseServices={caseServices}
configureCasesNavigation={configureCasesNavigation}
connectorName={connectorName}
connectors={connectors}
hasDataToPush={hasDataToPush && userCanCrud}
@ -520,97 +499,71 @@ export const CaseViewLoading = () => (
export const CaseView = React.memo(
({
allCasesNavigation,
caseDetailsNavigation,
caseId,
configureCasesNavigation,
getCaseDetailHrefWithCommentId,
onCaseDataSuccess,
onComponentInitialized,
actionsNavigation,
ruleDetailsNavigation,
showAlertDetails,
subCaseId,
timelineIntegration,
useFetchAlertData,
userCanCrud,
refreshRef,
hideSyncAlerts,
}: CaseViewProps) => {
const { spaces: spacesApi } = useKibana().services;
const { detailName: caseId, subCaseId } = useCaseViewParams();
const { basePath } = useCasesContext();
const { data, resolveOutcome, resolveAliasId, isLoading, isError, fetchCase, updateCase } =
useGetCase(caseId, subCaseId);
const { spaces: spacesApi, http } = useKibana().services;
useEffect(() => {
if (onCaseDataSuccess && data) {
onCaseDataSuccess(data);
}
}, [data, onCaseDataSuccess]);
useEffect(() => {
if (spacesApi && resolveOutcome === 'aliasMatch' && resolveAliasId != null) {
// CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
// Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
// under any another path, passing a path builder function by props from every parent plugin.
const newPath = http.basePath.prepend(
`cases/${resolveAliasId}${window.location.search}${window.location.hash}`
);
const newPath = `${basePath}${generateCaseViewPath({ detailName: resolveAliasId })}${
window.location.search
}${window.location.hash}`;
spacesApi.ui.redirectLegacyUrl(newPath, i18n.CASE);
}
}, [resolveOutcome, resolveAliasId, spacesApi, http]);
}, [resolveOutcome, resolveAliasId, basePath, spacesApi]);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
if (data && spacesApi && resolveOutcome === 'conflict' && resolveAliasId != null) {
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const otherObjectId = resolveAliasId; // This is always defined if outcome === 'conflict'
// CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
// Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
// under any another path, passing a path builder function by props from every parent plugin.
const otherObjectPath = http.basePath.prepend(
`cases/${otherObjectId}${window.location.search}${window.location.hash}`
);
const otherObjectPath = `${basePath}${generateCaseViewPath({
detailName: resolveAliasId,
})}${window.location.search}${window.location.hash}`;
return spacesApi.ui.components.getLegacyUrlConflict({
objectNoun: i18n.CASE,
currentObjectId: data.id,
otherObjectId,
otherObjectId: resolveAliasId,
otherObjectPath,
});
}
return null;
}, [data, resolveAliasId, resolveOutcome, spacesApi, http.basePath]);
}, [data, resolveAliasId, resolveOutcome, basePath, spacesApi]);
return isError ? (
<DoesNotExist allCasesNavigation={allCasesNavigation} caseId={caseId} />
<DoesNotExist caseId={caseId} />
) : isLoading ? (
<CaseViewLoading />
) : (
data && (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<OwnerProvider owner={[data.owner]}>
{getLegacyUrlConflictCallout()}
<CaseComponent
allCasesNavigation={allCasesNavigation}
caseData={data}
caseDetailsNavigation={caseDetailsNavigation}
caseId={caseId}
configureCasesNavigation={configureCasesNavigation}
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
fetchCase={fetchCase}
onCaseDataSuccess={onCaseDataSuccess}
onComponentInitialized={onComponentInitialized}
actionsNavigation={actionsNavigation}
ruleDetailsNavigation={ruleDetailsNavigation}
showAlertDetails={showAlertDetails}
subCaseId={subCaseId}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
refreshRef={refreshRef}
hideSyncAlerts={hideSyncAlerts}
/>
</OwnerProvider>
{getLegacyUrlConflictCallout()}
<CaseComponent
caseData={data}
caseId={caseId}
fetchCase={fetchCase}
onComponentInitialized={onComponentInitialized}
actionsNavigation={actionsNavigation}
ruleDetailsNavigation={ruleDetailsNavigation}
showAlertDetails={showAlertDetails}
subCaseId={subCaseId}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
refreshRef={refreshRef}
hideSyncAlerts={hideSyncAlerts}
/>
</CasesTimelineIntegrationProvider>
)
);

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import { DEFAULT_BASE_PATH } from '../../common/navigation';
import { useApplication } from './use_application';
export interface CasesContextValue {
owner: string[];
appId: string;
appTitle: string;
userCanCrud: boolean;
basePath: string;
}
export const CasesContext = React.createContext<CasesContextValue | undefined>(undefined);
export interface CasesContextProps
extends Omit<CasesContextValue, 'appId' | 'appTitle' | 'basePath'> {
basePath?: string;
}
export interface CasesContextStateValue
extends Omit<CasesContextValue, 'appId' | 'appTitle' | 'userCanCrud'> {
appId?: string;
appTitle?: string;
userCanCrud?: boolean;
}
export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
children,
value: { owner, userCanCrud, basePath = DEFAULT_BASE_PATH },
}) => {
const { appId, appTitle } = useApplication();
const [value, setValue] = useState<CasesContextStateValue>({
owner,
userCanCrud,
basePath,
});
/**
* `userCanCrud` prop may change by the parent plugin.
* `appId` and `appTitle` are dynamically retrieved from kibana context.
* We need to update the state if any of these values change, the rest of props are never updated.
*/
useEffect(() => {
if (appId && appTitle) {
setValue((prev) => ({
...prev,
appId,
appTitle,
userCanCrud,
}));
}
}, [appTitle, appId, userCanCrud]);
return isCasesContextValue(value) ? (
<CasesContext.Provider value={value}>{children}</CasesContext.Provider>
) : null;
};
function isCasesContextValue(value: CasesContextStateValue): value is CasesContextValue {
return value.appId != null && value.appTitle != null && value.userCanCrud != null;
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import useObservable from 'react-use/lib/useObservable';
import { useKibana } from '../../common/lib/kibana';
interface UseApplicationReturn {
appId: string | undefined;
appTitle: string | undefined;
}
export const useApplication = (): UseApplicationReturn => {
const { currentAppId$, applications$ } = useKibana().services.application;
// retrieve the most recent value from the BehaviorSubject
const appId = useObservable(currentAppId$);
const applications = useObservable(applications$);
const appTitle = appId ? applications?.get(appId)?.category?.label : undefined;
return { appId, appTitle };
};

View file

@ -6,16 +6,14 @@
*/
import { useContext } from 'react';
import { OwnerContext } from '.';
import { CasesContext } from '.';
export const useOwnerContext = () => {
const ownerContext = useContext(OwnerContext);
export const useCasesContext = () => {
const casesContext = useContext(CasesContext);
if (ownerContext.length === 0) {
throw new Error(
'useOwnerContext must be used within an OwnerProvider and not be an empty array'
);
if (!casesContext) {
throw new Error('useCasesContext must be used within a CasesProvider and have a defined value');
}
return ownerContext;
return casesContext;
};

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiToolTip } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import { CasesNavigation, LinkButton } from '../links';
// TODO: Potentially move into links component?
export interface ConfigureCaseButtonProps {
configureCasesNavigation: CasesNavigation;
isDisabled: boolean;
label: string;
msgTooltip: JSX.Element;
showToolTip: boolean;
titleTooltip: string;
}
const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
configureCasesNavigation: { href, onClick },
isDisabled,
label,
msgTooltip,
showToolTip,
titleTooltip,
}: ConfigureCaseButtonProps) => {
const configureCaseButton = useMemo(
() => (
<LinkButton
onClick={onClick}
href={href}
iconType="controlsHorizontal"
isDisabled={isDisabled}
aria-label={label}
data-test-subj="configure-case-button"
>
{label}
</LinkButton>
),
[label, isDisabled, onClick, href]
);
return showToolTip ? (
<EuiToolTip
position="top"
title={titleTooltip}
content={<p>{msgTooltip}</p>}
data-test-subj="configure-case-tooltip"
>
{configureCaseButton}
</EuiToolTip>
) : (
<>{configureCaseButton}</>
);
};
export const ConfigureCaseButton = memo(ConfigureCaseButtonComponent);

View file

@ -26,7 +26,7 @@ import {
useConnectorsResponse,
useActionTypesResponse,
} from './__mock__';
import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common';
import { ConnectorTypes } from '../../../common';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
jest.mock('../../common/lib/kibana');
@ -67,7 +67,7 @@ describe('ConfigureCases', () => {
useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] }));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -120,7 +120,7 @@ describe('ConfigureCases', () => {
}));
useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] }));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -167,7 +167,7 @@ describe('ConfigureCases', () => {
useConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -189,12 +189,10 @@ describe('ConfigureCases', () => {
});
test('it disables correctly when the user cannot crud', () => {
const newWrapper = mount(
<ConfigureCases userCanCrud={false} owner={[SECURITY_SOLUTION_OWNER]} />,
{
wrappingComponent: TestProviders,
}
);
const newWrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
wrappingComponentProps: { userCanCrud: false },
});
expect(newWrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled')).toBe(
true
@ -254,7 +252,7 @@ describe('ConfigureCases', () => {
}));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -289,7 +287,7 @@ describe('ConfigureCases', () => {
useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true }));
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
expect(wrapper.find(Connectors).prop('isLoading')).toBe(true);
@ -313,7 +311,7 @@ describe('ConfigureCases', () => {
useConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -356,7 +354,7 @@ describe('ConfigureCases', () => {
...useConnectorsResponse,
}));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -400,7 +398,7 @@ describe('ConfigureCases', () => {
useConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -444,7 +442,7 @@ describe('ConfigureCases', () => {
},
}));
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
@ -491,7 +489,7 @@ describe('ConfigureCases', () => {
useConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
});
@ -540,7 +538,7 @@ describe('ConfigureCases', () => {
});
test('it show the add flyout when pressing the add connector button', async () => {
const wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
const wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
@ -591,7 +589,7 @@ describe('ConfigureCases', () => {
.fn()
.mockReturnValue(true);
const wrapper = mount(<ConfigureCases userCanCrud owner={[SECURITY_SOLUTION_OWNER]} />, {
const wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
});
wrapper

View file

@ -22,14 +22,16 @@ import { ClosureType } from '../../containers/configure/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public/types';
import { SectionWrapper } from '../wrappers';
import { SectionWrapper, ContentWrapper, WhitePageWrapper } from '../wrappers';
import { Connectors } from './connectors';
import { ClosureOptions } from './closure_options';
import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils';
import * as i18n from './translations';
import { Owner } from '../../types';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
import { HeaderPage } from '../header_page';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCasesBreadcrumbs } from '../use_breadcrumbs';
import { CasesDeepLinkId } from '../../common/navigation';
const FormWrapper = styled.div`
${({ theme }) => css`
@ -49,12 +51,10 @@ const FormWrapper = styled.div`
`}
`;
export interface ConfigureCasesProps extends Owner {
userCanCrud: boolean;
}
const ConfigureCasesComponent: React.FC<Omit<ConfigureCasesProps, 'owner'>> = ({ userCanCrud }) => {
export const ConfigureCases: React.FC = React.memo(() => {
const { userCanCrud } = useCasesContext();
const { triggersActionsUi } = useKibana().services;
useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure);
const [connectorIsValid, setConnectorIsValid] = useState(true);
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
@ -185,64 +185,64 @@ const ConfigureCasesComponent: React.FC<Omit<ConfigureCasesProps, 'owner'>> = ({
);
return (
<FormWrapper>
{!connectorIsValid && (
<SectionWrapper style={{ marginTop: 0 }}>
<EuiCallOut
title={i18n.WARNING_NO_CONNECTOR_TITLE}
color="warning"
iconType="help"
data-test-subj="configure-cases-warning-callout"
>
<FormattedMessage
defaultMessage="The selected connector has been deleted or you do not have the {appropriateLicense} to use it. Either select a different connector or create a new one."
id="xpack.cases.configure.connectorDeletedOrLicenseWarning"
values={{
appropriateLicense: (
<EuiLink href="https://www.elastic.co/subscriptions" target="_blank">
{i18n.LINK_APPROPRIATE_LICENSE}
</EuiLink>
),
}}
/>
</EuiCallOut>
</SectionWrapper>
)}
<SectionWrapper>
<ClosureOptions
closureTypeSelected={closureType}
disabled={persistLoading || isLoadingConnectors || !userCanCrud}
onChangeClosureType={onChangeClosureType}
/>
</SectionWrapper>
<SectionWrapper>
<Connectors
actionTypes={actionTypes}
connectors={connectors ?? []}
disabled={persistLoading || isLoadingConnectors || !userCanCrud}
handleShowEditFlyout={onClickUpdateConnector}
isLoading={isLoadingAny}
mappings={mappings}
onChangeConnector={onChangeConnector}
selectedConnector={connector}
updateConnectorDisabled={updateConnectorDisabled || !userCanCrud}
/>
</SectionWrapper>
{ConnectorAddFlyout}
{ConnectorEditFlyout}
</FormWrapper>
);
};
export const ConfigureCases: React.FC<ConfigureCasesProps> = React.memo((props) => {
return (
<OwnerProvider owner={props.owner}>
<ConfigureCasesComponent {...props} />
</OwnerProvider>
<>
<HeaderPage
showBackButton={true}
data-test-subj="case-configure-title"
title={i18n.CONFIGURE_CASES_PAGE_TITLE}
/>
<WhitePageWrapper>
<ContentWrapper>
<FormWrapper>
{!connectorIsValid && (
<SectionWrapper style={{ marginTop: 0 }}>
<EuiCallOut
title={i18n.WARNING_NO_CONNECTOR_TITLE}
color="warning"
iconType="help"
data-test-subj="configure-cases-warning-callout"
>
<FormattedMessage
defaultMessage="The selected connector has been deleted or you do not have the {appropriateLicense} to use it. Either select a different connector or create a new one."
id="xpack.cases.configure.connectorDeletedOrLicenseWarning"
values={{
appropriateLicense: (
<EuiLink href="https://www.elastic.co/subscriptions" target="_blank">
{i18n.LINK_APPROPRIATE_LICENSE}
</EuiLink>
),
}}
/>
</EuiCallOut>
</SectionWrapper>
)}
<SectionWrapper>
<ClosureOptions
closureTypeSelected={closureType}
disabled={persistLoading || isLoadingConnectors || !userCanCrud}
onChangeClosureType={onChangeClosureType}
/>
</SectionWrapper>
<SectionWrapper>
<Connectors
actionTypes={actionTypes}
connectors={connectors ?? []}
disabled={persistLoading || isLoadingConnectors || !userCanCrud}
handleShowEditFlyout={onClickUpdateConnector}
isLoading={isLoadingAny}
mappings={mappings}
onChangeConnector={onChangeConnector}
selectedConnector={connector}
updateConnectorDisabled={updateConnectorDisabled || !userCanCrud}
/>
</SectionWrapper>
{ConnectorAddFlyout}
{ConnectorEditFlyout}
</FormWrapper>
</ContentWrapper>
</WhitePageWrapper>
</>
);
});
ConfigureCases.displayName = 'ConfigureCases';
// eslint-disable-next-line import/no-default-export
export default ConfigureCases;

View file

@ -176,3 +176,7 @@ export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate(
defaultMessage: 'This connector is deprecated. Update it, or create a new one.',
}
);
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', {
defaultMessage: 'Configure cases',
});

View file

@ -12,13 +12,12 @@ import styled from 'styled-components';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { ActionParamsProps } from '../../../../../triggers_actions_ui/public/types';
import { CommentType, SECURITY_SOLUTION_OWNER } from '../../../../common';
import { CommentType } from '../../../../common';
import { CaseActionParams } from './types';
import { ExistingCase } from './existing_case';
import * as i18n from './translations';
import { OwnerProvider } from '../../owner_context';
const Container = styled.div`
${({ theme }) => `
@ -96,9 +95,7 @@ const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionPara
*/
return (
<Container>
<OwnerProvider owner={[SECURITY_SOLUTION_OWNER]}>
<ExistingCase onCaseChanged={onCaseChanged} selectedCase={selectedCase} />
</OwnerProvider>
<ExistingCase onCaseChanged={onCaseChanged} selectedCase={selectedCase} />
<EuiSpacer size="m" />
<EuiCallOut size="s" title={i18n.CASE_CONNECTOR_CALL_OUT_TITLE} iconType="iInCircle">
<p>{i18n.CASE_CONNECTOR_CALL_OUT_MSG}</p>

View file

@ -6,7 +6,6 @@
*/
import React, { memo, useMemo, useCallback } from 'react';
import { CaseType } from '../../../../common';
import {
useGetCases,
DEFAULT_QUERY_PARAMS,
@ -43,7 +42,6 @@ const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, sel
const { modal, openModal } = useCreateCaseModal({
onCaseCreated,
caseType: CaseType.collection,
// FUTURE DEVELOPER
// We are making the assumption that this component is only used in rules creation
// that's why we want to hide ServiceNow SIR

View file

@ -21,22 +21,10 @@ import { schema, FormProps } from './schema';
import { TestProviders } from '../../common/mock';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { useKibana } from '../../common/lib/kibana';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
jest.mock('../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
notifications: {},
http: {},
triggersActionsUi: mockTriggersActionsUiService,
},
}),
}));
jest.mock('../../common/lib/kibana');
jest.mock('../connectors/resilient/use_get_incident_types');
jest.mock('../connectors/resilient/use_get_severity');
jest.mock('../connectors/servicenow/use_get_choices');
@ -93,7 +81,7 @@ describe('Connector', () => {
});
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetChoicesMock.mockReturnValue(useGetChoicesResponse);

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CreateCaseFlyout } from './create_case_flyout';
import { TestProviders } from '../../../common/mock';
jest.mock('../../../common/lib/kibana');
const onClose = jest.fn();
const onSuccess = jest.fn();
const defaultProps = {
onClose,
onSuccess,
owner: 'securitySolution',
};
describe('CreateCaseFlyout', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
const { getByTestId } = render(
<TestProviders>
<CreateCaseFlyout {...defaultProps} />
</TestProviders>
);
await act(async () => {
expect(getByTestId('create-case-flyout')).toBeTruthy();
});
});
it('Closing flyout calls onCloseCaseModal', async () => {
const { getByTestId } = render(
<TestProviders>
<CreateCaseFlyout {...defaultProps} />
</TestProviders>
);
await act(async () => {
userEvent.click(getByTestId('euiFlyoutCloseButton'));
});
expect(onClose).toBeCalled();
});
});

View file

@ -5,28 +5,25 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
import * as i18n from '../translations';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
import { Case } from '../../../../../../../cases/common';
import type { TimelinesStartServices } from '../../../../../types';
import { Case } from '../../../../common';
import { CreateCaseForm } from '../form';
export interface CreateCaseModalProps {
export interface CreateCaseFlyoutProps {
afterCaseCreated?: (theCase: Case) => Promise<void>;
onCloseFlyout: () => void;
onClose: () => void;
onSuccess: (theCase: Case) => Promise<void>;
useInsertTimeline?: Function;
owner: string;
disableAlerts?: boolean;
}
const StyledFlyout = styled(EuiFlyout)`
${({ theme }) => `
z-index: ${theme.eui.euiZLevel5};
`}
z-index: ${theme.eui.euiZModal};
`}
`;
const maskOverlayClassName = 'create-case-flyout-mask-overlay';
@ -49,47 +46,31 @@ const GlobalStyle = createGlobalStyle<{ theme: { eui: { euiZLevel5: number } } }
// bottom bar gonna hide the submit button.
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
${({ theme }) => `
&& .euiFlyoutBody__overflow {
overflow-y: auto;
overflow-x: hidden;
}
&& .euiFlyoutBody__overflowContent {
display: block;
padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 70px;
height: auto;
}
`}
&& .euiFlyoutBody__overflow {
overflow-y: auto;
overflow-x: hidden;
}
&& .euiFlyoutBody__overflowContent {
display: block;
padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 70px;
height: auto;
}
`}
`;
const FormWrapper = styled.div`
width: 100%;
`;
const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({
afterCaseCreated,
onCloseFlyout,
onSuccess,
owner,
disableAlerts,
}) => {
const { cases } = useKibana<TimelinesStartServices>().services;
const createCaseProps = useMemo(() => {
return {
afterCaseCreated,
onCancel: onCloseFlyout,
onSuccess,
withSteps: false,
owner: [owner],
disableAlerts,
};
}, [afterCaseCreated, onCloseFlyout, onSuccess, owner, disableAlerts]);
return (
export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
({ afterCaseCreated, onClose, onSuccess, disableAlerts }) => (
<>
<GlobalStyle />
<StyledFlyout
onClose={onCloseFlyout}
onClose={onClose}
data-test-subj="create-case-flyout"
// maskProps is needed in order to apply the z-index to the parent overlay element, not to the flyout only
maskProps={{ className: maskOverlayClassName }}
>
<EuiFlyoutHeader hasBorder>
@ -98,13 +79,19 @@ const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({
</EuiTitle>
</EuiFlyoutHeader>
<StyledEuiFlyoutBody>
<FormWrapper>{cases.getCreateCase(createCaseProps)}</FormWrapper>
<FormWrapper>
<CreateCaseForm
afterCaseCreated={afterCaseCreated}
onCancel={onClose}
onSuccess={onSuccess}
withSteps={false}
disableAlerts={disableAlerts}
/>
</FormWrapper>
</StyledEuiFlyoutBody>
</StyledFlyout>
</>
);
};
export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent);
)
);
CreateCaseFlyout.displayName = 'CreateCaseFlyout';

View file

@ -4,10 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CreateCaseFlyout, CreateCaseFlyoutProps } from './create_case_flyout';
export interface ErrorMessage {
id: string;
title: string;
description: JSX.Element;
errorType?: 'primary' | 'success' | 'warning' | 'danger';
}
export type { CreateCaseFlyoutProps };
export { CreateCaseFlyout };
// eslint-disable-next-line import/no-default-export
export { CreateCaseFlyout as default };

View file

@ -14,11 +14,10 @@ import { useGetTags } from '../../containers/use_get_tags';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/mock';
import { schema, FormProps } from './schema';
import { CreateCaseForm } from './form';
import { OwnerProvider } from '../owner_context';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { CreateCaseForm, CreateCaseFormFields, CreateCaseFormProps } from './form';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
import { TestProviders } from '../../common/mock';
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
@ -38,6 +37,11 @@ const initialCaseValue: FormProps = {
syncAlerts: true,
};
const casesFormProps: CreateCaseFormProps = {
onCancel: jest.fn(),
onSuccess: jest.fn(),
};
describe('CreateCaseForm', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
@ -50,9 +54,9 @@ describe('CreateCaseForm', () => {
globalForm = form;
return (
<OwnerProvider owner={[SECURITY_SOLUTION_OWNER]}>
<TestProviders>
<Form form={form}>{children}</Form>
</OwnerProvider>
</TestProviders>
);
};
@ -66,7 +70,7 @@ describe('CreateCaseForm', () => {
it('it renders with steps', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm />
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);
@ -76,7 +80,7 @@ describe('CreateCaseForm', () => {
it('it renders without steps', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm withSteps={false} />
<CreateCaseForm {...casesFormProps} withSteps={false} />
</MockHookWrapperComponent>
);
@ -86,7 +90,7 @@ describe('CreateCaseForm', () => {
it('it renders all form fields', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm />
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);
@ -97,21 +101,40 @@ describe('CreateCaseForm', () => {
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
});
it('should render spinner when loading', async () => {
const wrapper = mount(
it('hides the sync alerts toggle', () => {
const { queryByText } = render(
<MockHookWrapperComponent>
<CreateCaseForm />
<CreateCaseForm {...casesFormProps} disableAlerts />
</MockHookWrapperComponent>
);
await act(async () => {
globalForm.setFieldValue('title', 'title');
globalForm.setFieldValue('description', 'description');
globalForm.submit();
// For some weird reason this is needed to pass the test.
// It does not do anything useful
await wrapper.find(`[data-test-subj="caseTitle"]`);
await wrapper.update();
expect(queryByText('Sync alert')).not.toBeInTheDocument();
});
describe('CreateCaseFormFields', () => {
it('should render spinner when loading', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseFormFields
connectors={[]}
isLoadingConnectors={false}
disableAlerts={false}
hideConnectorServiceNowSir={false}
withSteps={true}
/>
</MockHookWrapperComponent>
);
await act(async () => {
globalForm.setFieldValue('title', 'title');
globalForm.setFieldValue('description', 'description');
globalForm.submit();
// For some weird reason this is needed to pass the test.
// It does not do anything useful
await wrapper.find(`[data-test-subj="caseTitle"]`);
await wrapper.update();
});
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()
@ -119,14 +142,4 @@ describe('CreateCaseForm', () => {
});
});
});
it('hides the sync alerts toggle', () => {
const { queryByText } = render(
<MockHookWrapperComponent>
<CreateCaseForm disableAlerts />
</MockHookWrapperComponent>
);
expect(queryByText('Sync alert')).not.toBeInTheDocument();
});
});

View file

@ -6,18 +6,30 @@
*/
import React, { useMemo } from 'react';
import { EuiLoadingSpinner, EuiSteps } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSteps,
} from '@elastic/eui';
import styled, { css } from 'styled-components';
import { useFormContext } from '../../common/shared_imports';
import { Title } from './title';
import { Description } from './description';
import { Description, fieldName as descriptionFieldName } from './description';
import { Tags } from './tags';
import { Connector } from './connector';
import * as i18n from './translations';
import { SyncAlertsToggle } from './sync_alerts_toggle';
import { ActionConnector } from '../../../common';
import { ActionConnector, CaseType } from '../../../common';
import { Case } from '../../containers/types';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { InsertTimeline } from '../insert_timeline';
import { UsePostComment } from '../../containers/use_post_comment';
import { SubmitCaseButton } from './submit_button';
import { FormContext } from './form_context';
interface ContainerProps {
big?: boolean;
@ -36,22 +48,28 @@ const MySpinner = styled(EuiLoadingSpinner)`
z-index: 99;
`;
interface Props {
connectors?: ActionConnector[];
disableAlerts?: boolean;
hideConnectorServiceNowSir?: boolean;
isLoadingConnectors?: boolean;
withSteps?: boolean;
export interface CreateCaseFormFieldsProps {
connectors: ActionConnector[];
isLoadingConnectors: boolean;
disableAlerts: boolean;
hideConnectorServiceNowSir: boolean;
withSteps: boolean;
}
export interface CreateCaseFormProps
extends Pick<
Partial<CreateCaseFormFieldsProps>,
'disableAlerts' | 'hideConnectorServiceNowSir' | 'withSteps'
> {
onCancel: () => void;
onSuccess: (theCase: Case) => Promise<void>;
afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise<void>;
caseType?: CaseType;
timelineIntegration?: CasesTimelineIntegration;
}
const empty: ActionConnector[] = [];
export const CreateCaseForm: React.FC<Props> = React.memo(
({
connectors = empty,
disableAlerts = false,
isLoadingConnectors = false,
hideConnectorServiceNowSir = false,
withSteps = true,
}) => {
export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo(
({ connectors, disableAlerts, isLoadingConnectors, hideConnectorServiceNowSir, withSteps }) => {
const { isSubmitting } = useFormContext();
const firstStep = useMemo(
() => ({
@ -126,4 +144,61 @@ export const CreateCaseForm: React.FC<Props> = React.memo(
}
);
CreateCaseFormFields.displayName = 'CreateCaseFormFields';
export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo(
({
disableAlerts = false,
hideConnectorServiceNowSir = false,
withSteps = true,
afterCaseCreated,
caseType,
onCancel,
onSuccess,
timelineIntegration,
}) => (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<FormContext
afterCaseCreated={afterCaseCreated}
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
onSuccess={onSuccess}
// if we are disabling alerts, then we should not sync alerts
syncAlertsDefaultValue={!disableAlerts}
>
<CreateCaseFormFields
connectors={empty}
disableAlerts={disableAlerts}
isLoadingConnectors={false}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
withSteps={withSteps}
/>
<Container>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="create-case-cancel"
iconType="cross"
onClick={onCancel}
size="s"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SubmitCaseButton />
</EuiFlexItem>
</EuiFlexGroup>
</Container>
<InsertTimeline fieldName={descriptionFieldName} />
</FormContext>
</CasesTimelineIntegrationProvider>
)
);
CreateCaseForm.displayName = 'CreateCaseForm';

View file

@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common';
import { ConnectorTypes } from '../../../common';
import { useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import { usePostCase } from '../../containers/use_post_case';
@ -36,7 +36,7 @@ import {
useGetChoicesResponse,
} from './mock';
import { FormContext } from './form_context';
import { CreateCaseForm } from './form';
import { CreateCaseFormFields, CreateCaseFormFieldsProps } from './form';
import { SubmitCaseButton } from './submit_button';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
@ -77,10 +77,12 @@ const defaultPostCase = {
postCase,
};
const defaultCreateCaseForm = {
const defaultCreateCaseForm: CreateCaseFormFieldsProps = {
isLoadingConnectors: false,
connectors: [],
owner: SECURITY_SOLUTION_OWNER,
disableAlerts: false,
withSteps: true,
hideConnectorServiceNowSir: false,
};
const defaultPostPushToService = {
@ -150,12 +152,14 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(wrapper.find(`[data-test-subj="caseTitle"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseDescription"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseTags"]`).first().exists()).toBeTruthy();
@ -174,7 +178,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -192,7 +196,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -224,7 +228,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -248,7 +252,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess} syncAlertsDefaultValue={false}>
<CreateCaseForm {...defaultCreateCaseForm} disableAlerts={true} />
<CreateCaseFormFields {...defaultCreateCaseForm} disableAlerts={true} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -282,7 +286,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -332,7 +336,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -357,7 +361,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -424,7 +428,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -494,7 +498,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -584,7 +588,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -682,7 +686,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
@ -719,7 +723,7 @@ describe('Create case', () => {
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}>
<CreateCaseForm {...defaultCreateCaseForm} />
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>

View file

@ -16,7 +16,7 @@ import { useConnectors } from '../../containers/configure/use_connectors';
import { Case } from '../../containers/types';
import { CaseType } from '../../../common';
import { UsePostComment, usePostComment } from '../../containers/use_post_comment';
import { useOwnerContext } from '../owner_context/use_owner_context';
import { useCasesContext } from '../cases_context/use_cases_context';
import { getConnectorById } from '../utils';
const initialCaseValue: FormProps = {
@ -46,7 +46,7 @@ export const FormContext: React.FC<Props> = ({
syncAlertsDefaultValue = true,
}) => {
const { connectors, loading: isLoadingConnectors } = useConnectors();
const owner = useOwnerContext();
const { owner } = useCasesContext();
const { postCase } = usePostCase();
const { postComment } = usePostComment();
const { pushCaseToExternalService } = usePostPushToService();

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { act } from '@testing-library/react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { TestProviders } from '../../common/mock';
@ -29,7 +29,6 @@ import {
useGetFieldsByIssueTypeResponse,
} from './mock';
import { CreateCase } from '.';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
jest.mock('../../containers/api');
jest.mock('../../containers/use_get_tags');
@ -78,7 +77,7 @@ const defaultProps = {
describe('CreateCase case', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
useConnectorsMock.mockReturnValue(sampleConnectorData);
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
@ -94,36 +93,38 @@ describe('CreateCase case', () => {
it('it renders', async () => {
const wrapper = mount(
<TestProviders>
<CreateCase {...defaultProps} owner={[SECURITY_SOLUTION_OWNER]} />
<CreateCase {...defaultProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy();
await act(async () => {
expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy();
});
});
it('should call cancel on cancel click', async () => {
const wrapper = mount(
<TestProviders>
<CreateCase {...defaultProps} owner={[SECURITY_SOLUTION_OWNER]} />
<CreateCase {...defaultProps} />
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click');
await act(async () => {
wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click');
});
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('should redirect to new case when posting the case', async () => {
const wrapper = mount(
<TestProviders>
<CreateCase {...defaultProps} owner={[SECURITY_SOLUTION_OWNER]} />
<CreateCase {...defaultProps} />
</TestProviders>
);
fillForm(wrapper);
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => {
expect(defaultProps.onSuccess).toHaveBeenCalled();
await act(async () => {
fillForm(wrapper);
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
expect(defaultProps.onSuccess).toHaveBeenCalled();
});
});

View file

@ -6,100 +6,49 @@
*/
import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { Field, getUseField } from '../../common/shared_imports';
import * as i18n from './translations';
import { CreateCaseForm } from './form';
import { FormContext } from './form_context';
import { SubmitCaseButton } from './submit_button';
import { Case } from '../../containers/types';
import { CaseType } from '../../../common';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { fieldName as descriptionFieldName } from './description';
import { InsertTimeline } from '../insert_timeline';
import { UsePostComment } from '../../containers/use_post_comment';
import { Owner } from '../../types';
import { OwnerProvider } from '../owner_context';
import { CreateCaseForm, CreateCaseFormProps } from './form';
import { HeaderPage } from '../header_page';
import { useCasesBreadcrumbs } from '../use_breadcrumbs';
import { CasesDeepLinkId } from '../../common/navigation';
export const CommonUseField = getUseField({ component: Field });
const Container = styled.div`
${({ theme }) => `
margin-top: ${theme.eui.euiSize};
`}
`;
export const CreateCase = React.memo<CreateCaseFormProps>(
({
afterCaseCreated,
caseType,
hideConnectorServiceNowSir,
disableAlerts,
onCancel,
onSuccess,
timelineIntegration,
withSteps,
}) => {
useCasesBreadcrumbs(CasesDeepLinkId.casesCreate);
export interface CreateCaseProps extends Owner {
afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise<void>;
caseType?: CaseType;
disableAlerts?: boolean;
hideConnectorServiceNowSir?: boolean;
onCancel: () => void;
onSuccess: (theCase: Case) => Promise<void>;
timelineIntegration?: CasesTimelineIntegration;
withSteps?: boolean;
}
const CreateCaseComponent = ({
afterCaseCreated,
caseType,
hideConnectorServiceNowSir,
disableAlerts,
onCancel,
onSuccess,
timelineIntegration,
withSteps,
}: Omit<CreateCaseProps, 'owner'>) => (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<FormContext
afterCaseCreated={afterCaseCreated}
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
onSuccess={onSuccess}
// if we are disabling alerts, then we should not sync alerts
syncAlertsDefaultValue={!disableAlerts}
>
<CreateCaseForm
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
disableAlerts={disableAlerts}
withSteps={withSteps}
/>
<Container>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="create-case-cancel"
iconType="cross"
onClick={onCancel}
size="s"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SubmitCaseButton />
</EuiFlexItem>
</EuiFlexGroup>
</Container>
<InsertTimeline fieldName={descriptionFieldName} />
</FormContext>
</CasesTimelineIntegrationProvider>
return (
<>
<HeaderPage
showBackButton={true}
data-test-subj="case-create-title"
title={i18n.CREATE_PAGE_TITLE}
/>
<CreateCaseForm
afterCaseCreated={afterCaseCreated}
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
disableAlerts={disableAlerts}
onCancel={onCancel}
onSuccess={onSuccess}
timelineIntegration={timelineIntegration}
withSteps={withSteps}
/>
</>
);
}
);
export const CreateCase: React.FC<CreateCaseProps> = React.memo((props) => (
<OwnerProvider owner={props.owner}>
<CreateCaseComponent {...props} />
</OwnerProvider>
));
CreateCase.displayName = 'CreateCase';
// eslint-disable-next-line import/no-default-export
export { CreateCase as default };

View file

@ -14,10 +14,11 @@ import { useForm, Form, FormHook } from '../../common/shared_imports';
import { useGetTags } from '../../containers/use_get_tags';
import { Tags } from './tags';
import { schema, FormProps } from './schema';
import { OwnerProvider } from '../owner_context';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { TestProviders } from '../../common/mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../containers/use_get_tags');
const useGetTagsMock = useGetTags as jest.Mock;
describe('Tags', () => {
@ -34,14 +35,14 @@ describe('Tags', () => {
globalForm = form;
return (
<OwnerProvider owner={[SECURITY_SOLUTION_OWNER]}>
<TestProviders>
<Form form={form}>{children}</Form>
</OwnerProvider>
</TestProviders>
);
};
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
useGetTagsMock.mockReturnValue({ tags: ['test'] });
});

View file

@ -9,6 +9,10 @@ import { i18n } from '@kbn/i18n';
export * from '../../common/translations';
export const CREATE_PAGE_TITLE = i18n.translate('xpack.cases.create.title', {
defaultMessage: 'Create new case',
});
export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', {
defaultMessage: 'Case fields',
});

View file

@ -32,10 +32,6 @@ const caseServices = {
const defaultProps: EditConnectorProps = {
caseData: basicCase,
caseServices,
configureCasesNavigation: {
href: 'blah',
onClick: jest.fn(),
},
connectorName: connectorsMock[0].name,
connectors: connectorsMock,
hasDataToPush: true,

View file

@ -30,13 +30,11 @@ import { getConnectorFieldsFromUserActions } from './helpers';
import * as i18n from './translations';
import { getConnectorById, getConnectorsFormValidators } from '../utils';
import { usePushToService } from '../use_push_to_service';
import { CasesNavigation } from '../links';
import { CaseServices } from '../../containers/use_get_case_user_actions';
export interface EditConnectorProps {
caseData: Case;
caseServices: CaseServices;
configureCasesNavigation: CasesNavigation;
connectorName: string;
connectors: ActionConnector[];
hasDataToPush: boolean;
@ -116,7 +114,6 @@ export const EditConnector = React.memo(
({
caseData,
caseServices,
configureCasesNavigation,
connectorName,
connectors,
hasDataToPush,
@ -250,7 +247,6 @@ export const EditConnector = React.memo(
});
const { pushButton, pushCallouts } = usePushToService({
configureCasesNavigation,
connector: {
...caseData.connector,
name: isEmpty(connectorName) ? caseData.connector.name : connectorName,

View file

@ -14,16 +14,7 @@ import { TestProviders } from '../../common/mock';
import { HeaderPage } from './index';
import { useMountAppended } from '../../utils/use_mount_appended';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
useHistory: jest.fn(),
}),
};
});
jest.mock('../../common/navigation/hooks');
describe('HeaderPage', () => {
const mount = useMountAppended();
@ -47,10 +38,7 @@ describe('HeaderPage', () => {
test('it renders the back link when provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderPage
backOptions={{ href: '#', text: 'Test link', onClick: jest.fn() }}
title="Test title"
/>
<HeaderPage showBackButton title="Test title" />
</TestProviders>
);

View file

@ -5,14 +5,17 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
import React from 'react';
import styled, { css } from 'styled-components';
import { useAllCasesNavigation } from '../../common/navigation';
import { LinkIcon, LinkIconProps } from '../link_icon';
import { LinkIcon } from '../link_icon';
import { Subtitle, SubtitleProps } from '../subtitle';
import { Title } from './title';
import { BadgeOptions, TitleProp } from './types';
import * as i18n from './translations';
interface HeaderProps {
border?: boolean;
isLoading?: boolean;
@ -57,17 +60,8 @@ const Badge = styled(EuiBadge)`
` as unknown as typeof EuiBadge;
Badge.displayName = 'Badge';
interface BackOptions {
href: LinkIconProps['href'];
onClick?: (ev: MouseEvent) => void;
text: LinkIconProps['children'];
dataTestSubj?: string;
}
export interface HeaderPageProps extends HeaderProps {
backOptions?: BackOptions;
/** A component to be displayed as the back button. Used only if `backOption` is not defined */
backComponent?: React.ReactNode;
showBackButton?: boolean;
badgeOptions?: BadgeOptions;
children?: React.ReactNode;
subtitle?: SubtitleProps['items'];
@ -77,8 +71,7 @@ export interface HeaderPageProps extends HeaderProps {
}
const HeaderPageComponent: React.FC<HeaderPageProps> = ({
backOptions,
backComponent,
showBackButton = false,
badgeOptions,
border,
children,
@ -88,39 +81,51 @@ const HeaderPageComponent: React.FC<HeaderPageProps> = ({
title,
titleNode,
...rest
}) => (
<Header border={border} {...rest}>
<EuiFlexGroup alignItems="center">
<FlexItem>
{backOptions && (
<LinkBack>
<LinkIcon
dataTestSubj={backOptions.dataTestSubj}
onClick={backOptions.onClick}
href={backOptions.href}
iconType="arrowLeft"
>
{backOptions.text}
</LinkIcon>
</LinkBack>
)}
}) => {
const { getAllCasesUrl, navigateToAllCases } = useAllCasesNavigation();
{!backOptions && backComponent && <>{backComponent}</>}
const navigateToAllCasesClick = useCallback(
(e) => {
if (e) {
e.preventDefault();
}
navigateToAllCases();
},
[navigateToAllCases]
);
{titleNode || <Title title={title} badgeOptions={badgeOptions} />}
return (
<Header border={border} {...rest}>
<EuiFlexGroup alignItems="center">
<FlexItem>
{showBackButton && (
<LinkBack>
<LinkIcon
dataTestSubj="backToCases"
onClick={navigateToAllCasesClick}
href={getAllCasesUrl()}
iconType="arrowLeft"
>
{i18n.BACK_TO_ALL}
</LinkIcon>
</LinkBack>
)}
{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>
{titleNode || <Title title={title} badgeOptions={badgeOptions} />}
{children && (
<FlexItem data-test-subj="header-page-supplements" grow={false}>
{children}
{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>
)}
</EuiFlexGroup>
</Header>
);
{children && (
<FlexItem data-test-subj="header-page-supplements" grow={false}>
{children}
</FlexItem>
)}
</EuiFlexGroup>
</Header>
);
};
export const HeaderPage = React.memo(HeaderPageComponent);

View file

@ -7,30 +7,25 @@
import React from 'react';
import { ReactWrapper, mount } from 'enzyme';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EuiText } from '@elastic/eui';
import '../../common/mock/match_media';
import { ConfigureCaseButton, ConfigureCaseButtonProps } from './button';
import {
ConfigureCaseButton,
ConfigureCaseButtonProps,
CaseDetailsLink,
CaseDetailsLinkProps,
} from '.';
import { TestProviders } from '../../common/mock';
import { useCaseViewNavigation } from '../../common/navigation/hooks';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
useHistory: jest.fn(),
}),
};
});
jest.mock('../../common/navigation/hooks');
describe('Configuration button', () => {
let wrapper: ReactWrapper;
const props: ConfigureCaseButtonProps = {
configureCasesNavigation: {
href: 'testHref',
onClick: jest.fn(),
},
isDisabled: false,
label: 'My label',
msgTooltip: <></>,
@ -50,7 +45,7 @@ describe('Configuration button', () => {
test('it pass the correct props to the button', () => {
expect(wrapper.find('[data-test-subj="configure-case-button"]').first().props()).toMatchObject({
href: `testHref`,
href: `/app/security/cases/configure`,
iconType: 'controlsHorizontal',
isDisabled: false,
'aria-label': 'My label',
@ -111,3 +106,60 @@ describe('Configuration button', () => {
jest.clearAllMocks();
});
});
describe('CaseDetailsLink', () => {
const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock;
const getCaseViewUrl = jest.fn().mockReturnValue('/cases/test');
const navigateToCaseView = jest.fn();
const props: CaseDetailsLinkProps = {
detailName: 'test detail name',
};
beforeEach(() => {
jest.clearAllMocks();
useCaseViewNavigationMock.mockReturnValue({ getCaseViewUrl, navigateToCaseView });
});
test('it renders', () => {
render(<CaseDetailsLink {...props} />);
expect(screen.getByText('test detail name')).toBeInTheDocument();
});
test('it renders the children instead of the detail name if provided', () => {
render(<CaseDetailsLink {...props}>{'children'}</CaseDetailsLink>);
expect(screen.queryByText('test detail name')).toBeFalsy();
expect(screen.getByText('children')).toBeInTheDocument();
});
test('it uses the detailName in the aria-label if the title is not provided', () => {
render(<CaseDetailsLink {...props} />);
expect(
screen.getByLabelText(`click to visit case with title ${props.detailName}`)
).toBeInTheDocument();
});
test('it uses the title in the aria-label if provided', () => {
render(<CaseDetailsLink {...props} title={'my title'} />);
expect(screen.getByText('test detail name')).toBeInTheDocument();
expect(screen.getByLabelText(`click to visit case with title my title`)).toBeInTheDocument();
});
test('it calls navigateToCaseViewClick on click', () => {
render(<CaseDetailsLink {...props} subCaseId="sub-case-id" />);
userEvent.click(screen.getByText('test detail name'));
expect(navigateToCaseView).toHaveBeenCalledWith({
detailName: props.detailName,
subCaseId: 'sub-case-id',
});
});
test('it set the href correctly', () => {
render(<CaseDetailsLink {...props} subCaseId="sub-case-id" />);
expect(getCaseViewUrl).toHaveBeenCalledWith({
detailName: props.detailName,
subCaseId: 'sub-case-id',
});
expect(screen.getByRole('link')).toHaveAttribute('href', '/cases/test');
});
});

View file

@ -10,10 +10,12 @@ import {
EuiButtonProps,
EuiLink,
EuiLinkProps,
EuiToolTip,
PropsForAnchor,
PropsForButton,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useCaseViewNavigation, useConfigureCasesNavigation } from '../../common/navigation';
import * as i18n from './translations';
export interface CasesNavigation<T = React.MouseEvent | MouseEvent | null, K = null> {
@ -30,36 +32,32 @@ export const LinkAnchor: React.FC<EuiLinkProps> = ({ children, ...props }) => (
<EuiLink {...props}>{children}</EuiLink>
);
export interface CaseDetailsHrefSchema {
detailName: string;
search?: string;
subCaseId?: string;
}
const CaseDetailsLinkComponent: React.FC<{
export interface CaseDetailsLinkProps {
children?: React.ReactNode;
detailName: string;
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
subCaseId?: string;
title?: string;
}> = ({ caseDetailsNavigation, children, detailName, subCaseId, title }) => {
const { href: getHref, onClick } = caseDetailsNavigation;
const goToCaseDetails = useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
onClick({ detailName, subCaseId }, ev);
}
},
[detailName, onClick, subCaseId]
);
}
const href = getHref({ detailName, subCaseId });
const CaseDetailsLinkComponent: React.FC<CaseDetailsLinkProps> = ({
children,
detailName,
subCaseId,
title,
}) => {
const { getCaseViewUrl, navigateToCaseView } = useCaseViewNavigation();
const navigateToCaseViewClick = useCallback(
(ev) => {
ev.preventDefault();
navigateToCaseView({ detailName, subCaseId });
},
[navigateToCaseView, detailName, subCaseId]
);
return (
<LinkAnchor
onClick={goToCaseDetails}
href={href}
onClick={navigateToCaseViewClick}
href={getCaseViewUrl({ detailName, subCaseId })}
data-test-subj="case-details-link"
aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)}
>
@ -69,3 +67,61 @@ const CaseDetailsLinkComponent: React.FC<{
};
export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent);
CaseDetailsLink.displayName = 'CaseDetailsLink';
export interface ConfigureCaseButtonProps {
isDisabled: boolean;
label: string;
msgTooltip: JSX.Element;
showToolTip: boolean;
titleTooltip: string;
}
const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
isDisabled,
label,
msgTooltip,
showToolTip,
titleTooltip,
}: ConfigureCaseButtonProps) => {
const { getConfigureCasesUrl, navigateToConfigureCases } = useConfigureCasesNavigation();
const navigateToConfigureCasesClick = useCallback(
(e) => {
e.preventDefault();
navigateToConfigureCases();
},
[navigateToConfigureCases]
);
const configureCaseButton = useMemo(
() => (
<LinkButton
onClick={navigateToConfigureCasesClick}
href={getConfigureCasesUrl()}
iconType="controlsHorizontal"
isDisabled={isDisabled}
aria-label={label}
data-test-subj="configure-case-button"
>
{label}
</LinkButton>
),
[label, isDisabled, navigateToConfigureCasesClick, getConfigureCasesUrl]
);
return showToolTip ? (
<EuiToolTip
position="top"
title={titleTooltip}
content={<p>{msgTooltip}</p>}
data-test-subj="configure-case-tooltip"
>
{configureCaseButton}
</EuiToolTip>
) : (
<>{configureCaseButton}</>
);
};
export const ConfigureCaseButton = React.memo(ConfigureCaseButtonComponent);
ConfigureCaseButton.displayName = 'ConfigureCaseButton';

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import * as i18n from './translations';
import { useAllCasesNavigation } from '../../common/navigation';
interface NoPrivilegesPageProps {
pageName: string;
}
export const NoPrivilegesPage = React.memo(({ pageName }: NoPrivilegesPageProps) => {
const { navigateToAllCases } = useAllCasesNavigation();
return (
<EuiEmptyPrompt
iconColor="default"
iconType="addDataApp"
title={<h2>{i18n.NO_PRIVILEGES_TITLE}</h2>}
titleSize="xs"
body={<p>{i18n.NO_PRIVILEGES_MSG(pageName)}</p>}
actions={
<EuiButton onClick={navigateToAllCases} size="s" color="primary" fill>
{i18n.NO_PRIVILEGES_BUTTON}
</EuiButton>
}
/>
);
});
NoPrivilegesPage.displayName = 'NoPrivilegesPage';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const NO_PRIVILEGES_MSG = (pageName: string) =>
i18n.translate('xpack.cases.noPrivileges.message', {
values: { pageName },
defaultMessage:
'To view {pageName} page, you must update privileges. For more information, contact your Kibana administrator.',
});
export const NO_PRIVILEGES_TITLE = i18n.translate('xpack.cases.noPrivileges.title', {
defaultMessage: 'Privileges required',
});
export const NO_PRIVILEGES_BUTTON = i18n.translate('xpack.cases.noPrivileges.button', {
defaultMessage: 'Back to Cases',
});

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
export const OwnerContext = React.createContext<string[]>([]);
export const OwnerProvider: React.FC<{
owner: string[];
}> = ({ children, owner }) => {
const [currentOwner] = useState(owner);
return <OwnerContext.Provider value={currentOwner}>{children}</OwnerContext.Provider>;
};

View file

@ -8,33 +8,19 @@
import React from 'react';
import { configure, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RecentCases from '.';
import RecentCases, { RecentCasesProps } from '.';
import { TestProviders } from '../../common/mock';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesMockState } from '../../containers/mock';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { useCurrentUser } from '../../common/lib/kibana/hooks';
jest.mock('../../containers/use_get_cases');
jest.mock('../../common/lib/kibana/hooks');
jest.mock('../../common/navigation/hooks');
configure({ testIdAttribute: 'data-test-subj' });
const defaultProps = {
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: () => 'case-details-href',
onClick: jest.fn(),
},
createCaseNavigation: {
href: 'create-details-href',
onClick: jest.fn(),
},
hasWritePermissions: true,
const defaultProps: RecentCasesProps = {
maxCasesToShow: 10,
owner: [SECURITY_SOLUTION_OWNER],
};
const setFilters = jest.fn();

View file

@ -6,36 +6,35 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText, EuiTitle } from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import * as i18n from './translations';
import { CaseDetailsHrefSchema, CasesNavigation, LinkAnchor } from '../links';
import { LinkAnchor } from '../links';
import { RecentCasesFilters } from './filters';
import { RecentCasesComp } from './recent_cases';
import { FilterMode as RecentCasesFilterMode } from './types';
import { useCurrentUser } from '../../common/lib/kibana';
import { Owner } from '../../types';
import { OwnerProvider } from '../owner_context';
import { useAllCasesNavigation } from '../../common/navigation';
export interface RecentCasesProps extends Owner {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
createCaseNavigation: CasesNavigation;
hasWritePermissions: boolean;
export interface RecentCasesProps {
maxCasesToShow: number;
}
const RecentCasesComponent = ({
allCasesNavigation,
caseDetailsNavigation,
createCaseNavigation,
maxCasesToShow,
hasWritePermissions,
}: Omit<RecentCasesProps, 'owner'>) => {
const RecentCases = React.memo(({ maxCasesToShow }: RecentCasesProps) => {
const currentUser = useCurrentUser();
const { getAllCasesUrl, navigateToAllCases } = useAllCasesNavigation();
const [recentCasesFilterBy, setRecentCasesFilterBy] =
useState<RecentCasesFilterMode>('recentlyCreated');
const navigateToAllCasesClick = useCallback(
(e) => {
e.preventDefault();
navigateToAllCases();
},
[navigateToAllCases]
);
const recentCasesFilterOptions = useMemo(
() =>
recentCasesFilterBy === 'myRecentlyReported' && currentUser != null
@ -73,16 +72,10 @@ const RecentCasesComponent = ({
<EuiHorizontalRule margin="s" />
</>
<EuiText color="subdued" size="s">
<RecentCasesComp
caseDetailsNavigation={caseDetailsNavigation}
createCaseNavigation={createCaseNavigation}
filterOptions={recentCasesFilterOptions}
maxCasesToShow={maxCasesToShow}
hasWritePermissions={hasWritePermissions}
/>
<RecentCasesComp filterOptions={recentCasesFilterOptions} maxCasesToShow={maxCasesToShow} />
<EuiHorizontalRule margin="s" />
<EuiText size="xs">
<LinkAnchor onClick={allCasesNavigation.onClick} href={allCasesNavigation.href}>
<LinkAnchor onClick={navigateToAllCasesClick} href={getAllCasesUrl()}>
{' '}
{i18n.VIEW_ALL_CASES}
</LinkAnchor>
@ -90,14 +83,6 @@ const RecentCasesComponent = ({
</EuiText>
</>
);
};
export const RecentCases: React.FC<RecentCasesProps> = React.memo((props) => {
return (
<OwnerProvider owner={props.owner}>
<RecentCasesComponent {...props} />
</OwnerProvider>
);
});
RecentCases.displayName = 'RecentCases';

View file

@ -11,24 +11,24 @@ import { mount } from 'enzyme';
import { TestProviders } from '../../../common/mock';
import { NoCases } from '.';
describe('RecentCases', () => {
jest.mock('../../../common/navigation/hooks');
describe('NoCases', () => {
it('if no cases, a link to create cases will exist', () => {
const createCaseHref = '/create';
const wrapper = mount(
<TestProviders>
<NoCases createCaseHref={createCaseHref} hasWritePermissions />
<NoCases />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual(
createCaseHref
'/app/security/cases/create'
);
});
it('displays a message without a link to create a case when the user does not have write permissions', () => {
const createCaseHref = '/create';
const wrapper = mount(
<TestProviders>
<NoCases createCaseHref={createCaseHref} hasWritePermissions={false} />
<TestProviders userCanCrud={false}>
<NoCases />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy();

View file

@ -8,31 +8,29 @@
import React, { useCallback } from 'react';
import * as i18n from '../translations';
import { useKibana } from '../../../common/lib/kibana';
import { LinkAnchor } from '../../links';
import { useCasesContext } from '../../cases_context/use_cases_context';
import { useCreateCaseNavigation } from '../../../common/navigation';
const NoCasesComponent = ({
createCaseHref,
hasWritePermissions,
}: {
createCaseHref: string;
hasWritePermissions: boolean;
}) => {
const { navigateToUrl } = useKibana().services.application;
const goToCaseCreation = useCallback(
const NoCasesComponent = () => {
const { userCanCrud } = useCasesContext();
const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation();
const navigateToCreateCaseClick = useCallback(
(e) => {
e.preventDefault();
navigateToUrl(createCaseHref);
navigateToCreateCase();
},
[createCaseHref, navigateToUrl]
[navigateToCreateCase]
);
return hasWritePermissions ? (
return userCanCrud ? (
<>
<span>{i18n.NO_CASES}</span>
<LinkAnchor
data-test-subj="no-cases-create-case"
onClick={goToCaseCreation}
href={createCaseHref}
onClick={navigateToCreateCaseClick}
href={getCreateCaseUrl()}
>{` ${i18n.START_A_NEW_CASE}`}</LinkAnchor>
{'!'}
</>

View file

@ -13,7 +13,7 @@ import styled from 'styled-components';
import { IconWithCount } from './icon_with_count';
import * as i18n from './translations';
import { useGetCases } from '../../containers/use_get_cases';
import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links';
import { CaseDetailsLink } from '../links';
import { LoadingPlaceholders } from './loading_placeholders';
import { NoCases } from './no_cases';
import { isSubCase } from '../all_cases/helpers';
@ -29,10 +29,7 @@ const MarkdownContainer = styled.div`
export interface RecentCasesProps {
filterOptions: Partial<FilterOptions>;
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
createCaseNavigation: CasesNavigation;
maxCasesToShow: number;
hasWritePermissions: boolean;
}
const usePrevious = (value: Partial<FilterOptions>) => {
@ -43,13 +40,7 @@ const usePrevious = (value: Partial<FilterOptions>) => {
return ref.current;
};
export const RecentCasesComp = ({
caseDetailsNavigation,
createCaseNavigation,
filterOptions,
maxCasesToShow,
hasWritePermissions,
}: RecentCasesProps) => {
export const RecentCasesComp = ({ filterOptions, maxCasesToShow }: RecentCasesProps) => {
const previousFilterOptions = usePrevious(filterOptions);
const { data, loading, setFilters } = useGetCases({
initialQueryParams: { perPage: maxCasesToShow },
@ -69,7 +60,7 @@ export const RecentCasesComp = ({
return isLoadingCases ? (
<LoadingPlaceholders lines={2} placeholders={3} />
) : !isLoadingCases && data.cases.length === 0 ? (
<NoCases createCaseHref={createCaseNavigation.href} hasWritePermissions={hasWritePermissions} />
<NoCases />
) : (
<>
{data.cases.map((c, i) => (
@ -77,7 +68,6 @@ export const RecentCasesComp = ({
<EuiFlexItem grow={false}>
<EuiText size="s">
<CaseDetailsLink
caseDetailsNavigation={caseDetailsNavigation}
detailName={isSubCase(c) ? c.caseParentId : c.id}
title={c.title}
subCaseId={isSubCase(c) ? c.id : undefined}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ReactNode } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../common/mock';
import { useCasesBreadcrumbs, useCasesTitleBreadcrumbs } from '.';
import { CasesDeepLinkId } from '../../common/navigation';
const mockSetBreadcrumbs = jest.fn();
const mockSetTitle = jest.fn();
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useNavigation: jest.fn().mockReturnValue({
getAppUrl: jest.fn((params?: { deepLinkId: string }) => params?.deepLinkId ?? '/test'),
}),
useKibana: () => {
const { services } = originalModule.useKibana();
return {
services: {
...services,
chrome: { setBreadcrumbs: mockSetBreadcrumbs, docTitle: { change: mockSetTitle } },
},
};
},
};
});
const wrapper = ({ children }: { children?: ReactNode }) => (
<TestProviders>{children}</TestProviders>
);
describe('useCasesBreadcrumbs', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('set all_cases breadcrumbs', () => {
it('call setBreadcrumbs with all items', async () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.cases), { wrapper });
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ href: '/test', onClick: expect.any(Function), text: 'Test' },
{ text: 'Cases' },
]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.cases), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith(['Cases', 'Test']);
});
});
describe('set create_case breadcrumbs', () => {
it('call setBreadcrumbs with all items', () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.casesCreate), { wrapper });
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ href: '/test', onClick: expect.any(Function), text: 'Test' },
{ href: CasesDeepLinkId.cases, onClick: expect.any(Function), text: 'Cases' },
{ text: 'Create' },
]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.casesCreate), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith(['Create', 'Cases', 'Test']);
});
});
describe('set case_view breadcrumbs', () => {
const title = 'Fake Title';
it('call setBreadcrumbs with title', () => {
renderHook(() => useCasesTitleBreadcrumbs(title), { wrapper });
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ href: '/test', onClick: expect.any(Function), text: 'Test' },
{ href: CasesDeepLinkId.cases, onClick: expect.any(Function), text: 'Cases' },
{ text: title },
]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesTitleBreadcrumbs(title), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith([title, 'Cases', 'Test']);
});
});
});

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { ChromeBreadcrumb } from 'kibana/public';
import { useCallback, useEffect } from 'react';
import { useKibana, useNavigation } from '../../common/lib/kibana';
import { CasesDeepLinkId, ICasesDeepLinkId } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
const casesBreadcrumbTitle: Record<ICasesDeepLinkId, string> = {
[CasesDeepLinkId.cases]: i18n.translate('xpack.cases.breadcrumbs.all_cases', {
defaultMessage: 'Cases',
}),
[CasesDeepLinkId.casesCreate]: i18n.translate('xpack.cases.breadcrumbs.create_case', {
defaultMessage: 'Create',
}),
[CasesDeepLinkId.casesConfigure]: i18n.translate('xpack.cases.breadcrumbs.configure_cases', {
defaultMessage: 'Configure',
}),
};
function getTitleFromBreadcrumbs(breadcrumbs: ChromeBreadcrumb[]): string[] {
return breadcrumbs.map(({ text }) => text?.toString() ?? '').reverse();
}
const useApplyBreadcrumbs = () => {
const {
chrome: { docTitle, setBreadcrumbs },
application: { navigateToUrl },
} = useKibana().services;
return useCallback(
(breadcrumbs: ChromeBreadcrumb[]) => {
docTitle.change(getTitleFromBreadcrumbs(breadcrumbs));
setBreadcrumbs(
breadcrumbs.map((breadcrumb) => {
const { href, onClick } = breadcrumb;
return {
...breadcrumb,
...(href && !onClick
? {
onClick: (event) => {
if (event) {
event.preventDefault();
}
navigateToUrl(href);
},
}
: {}),
};
})
);
},
[docTitle, setBreadcrumbs, navigateToUrl]
);
};
export const useCasesBreadcrumbs = (pageDeepLink: ICasesDeepLinkId) => {
const { appId, appTitle } = useCasesContext();
const { getAppUrl } = useNavigation(appId);
const applyBreadcrumbs = useApplyBreadcrumbs();
useEffect(() => {
applyBreadcrumbs([
{ text: appTitle, href: getAppUrl() },
{
text: casesBreadcrumbTitle[CasesDeepLinkId.cases],
...(pageDeepLink !== CasesDeepLinkId.cases
? {
href: getAppUrl({ deepLinkId: CasesDeepLinkId.cases }),
}
: {}),
},
...(pageDeepLink !== CasesDeepLinkId.cases
? [
{
text: casesBreadcrumbTitle[pageDeepLink],
},
]
: []),
]);
}, [pageDeepLink, appTitle, getAppUrl, applyBreadcrumbs]);
};
export const useCasesTitleBreadcrumbs = (caseTitle: string) => {
const { appId, appTitle } = useCasesContext();
const { getAppUrl } = useNavigation(appId);
const applyBreadcrumbs = useApplyBreadcrumbs();
useEffect(() => {
const casesBreadcrumbs: ChromeBreadcrumb[] = [
{ text: appTitle, href: getAppUrl() },
{
text: casesBreadcrumbTitle[CasesDeepLinkId.cases],
href: getAppUrl({ deepLinkId: CasesDeepLinkId.cases }),
},
{
text: caseTitle,
},
];
applyBreadcrumbs(casesBreadcrumbs);
}, [caseTitle, appTitle, getAppUrl, applyBreadcrumbs]);
};

View file

@ -10,11 +10,15 @@ import { mount } from 'enzyme';
import { CreateCaseModal } from './create_case_modal';
import { TestProviders } from '../../common/mock';
import { getCreateCaseLazy as getCreateCase } from '../../methods';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { CreateCase } from '../create';
jest.mock('../create', () => ({
CreateCase: jest.fn(),
}));
const CreateCaseMock = CreateCase as unknown as jest.Mock;
jest.mock('../../methods');
const getCreateCaseMock = getCreateCase as jest.Mock;
const onCloseCaseModal = jest.fn();
const onSuccess = jest.fn();
const defaultProps = {
@ -26,8 +30,8 @@ const defaultProps = {
describe('CreateCaseModal', () => {
beforeEach(() => {
jest.resetAllMocks();
getCreateCaseMock.mockReturnValue(<></>);
jest.clearAllMocks();
CreateCaseMock.mockReturnValue(<></>);
});
it('renders', () => {
@ -68,7 +72,7 @@ describe('CreateCaseModal', () => {
</TestProviders>
);
expect(getCreateCaseMock.mock.calls[0][0]).toEqual(
expect(CreateCaseMock.mock.calls[0][0]).toEqual(
expect.objectContaining({
onSuccess,
onCancel: onCloseCaseModal,

View file

@ -10,25 +10,20 @@ import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@el
import { Case } from '../../containers/types';
import * as i18n from '../../common/translations';
import { CaseType } from '../../../common';
import { getCreateCaseLazy as getCreateCase } from '../../methods';
import { CreateCase } from '../create';
export interface CreateCaseModalProps {
caseType?: CaseType;
hideConnectorServiceNowSir?: boolean;
isModalOpen: boolean;
onCloseCaseModal: () => void;
onSuccess: (theCase: Case) => Promise<void>;
owner: string;
}
const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
caseType = CaseType.individual,
hideConnectorServiceNowSir,
isModalOpen,
onCloseCaseModal,
onSuccess,
owner,
}) =>
isModalOpen ? (
<EuiModal onClose={onCloseCaseModal} data-test-subj="create-case-modal">
@ -36,14 +31,12 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
<EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{getCreateCase({
caseType,
hideConnectorServiceNowSir,
onCancel: onCloseCaseModal,
onSuccess,
withSteps: false,
owner: [owner],
})}
<CreateCase
onCancel={onCloseCaseModal}
onSuccess={onSuccess}
withSteps={false}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
/>
</EuiModalBody>
</EuiModal>
) : null;

View file

@ -7,7 +7,7 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { render } from '@testing-library/react';
import { render, act as reactAct } from '@testing-library/react';
import { useKibana } from '../../common/lib/kibana';
import { useCreateCaseModal, UseCreateCaseModalProps, UseCreateCaseModalReturnedValues } from '.';
@ -95,8 +95,10 @@ describe('useCreateCaseModal', () => {
result.current.openModal();
});
const modal = result.current.modal;
render(<TestProviders>{modal}</TestProviders>);
await reactAct(async () => {
const modal = result.current.modal;
render(<TestProviders>{modal}</TestProviders>);
});
act(() => {
result.current.modal.props.onSuccess({ id: 'case-id' });

View file

@ -6,13 +6,11 @@
*/
import React, { useState, useCallback, useMemo } from 'react';
import { Case, CaseType } from '../../../common';
import { useOwnerContext } from '../owner_context/use_owner_context';
import { Case } from '../../../common';
import { CreateCaseModal } from './create_case_modal';
export interface UseCreateCaseModalProps {
onCaseCreated: (theCase: Case) => void;
caseType?: CaseType;
hideConnectorServiceNowSir?: boolean;
}
export interface UseCreateCaseModalReturnedValues {
@ -23,11 +21,9 @@ export interface UseCreateCaseModalReturnedValues {
}
export const useCreateCaseModal = ({
caseType = CaseType.individual,
onCaseCreated,
hideConnectorServiceNowSir = false,
}: UseCreateCaseModalProps) => {
const owner = useOwnerContext();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
@ -43,18 +39,16 @@ export const useCreateCaseModal = ({
() => ({
modal: (
<CreateCaseModal
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onSuccess={onSuccess}
owner={owner[0]}
/>
),
isModalOpen,
closeModal,
openModal,
}),
[caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal, owner]
[closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal]
);
};

View file

@ -18,10 +18,6 @@ describe('CaseCallOut ', () => {
});
const defaultProps: CaseCallOutProps = {
configureCasesNavigation: {
href: 'testHref',
onClick: jest.fn(),
},
hasConnectors: true,
messages: [
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
@ -83,7 +79,6 @@ describe('CaseCallOut ', () => {
const id = createCalloutId(['message-one', 'message-two']);
wrapper.find(`[data-test-subj="callout-onclick-${id}"]`).last().simulate('click');
expect(defaultProps.onEditClick).toHaveBeenCalled();
expect(defaultProps.configureCasesNavigation.onClick).not.toHaveBeenCalled();
});
it('Redirects to configure page when hasConnectors=false', () => {
@ -100,6 +95,5 @@ describe('CaseCallOut ', () => {
const id = createCalloutId(['message-one', 'message-two']);
wrapper.find(`[data-test-subj="callout-onclick-${id}"]`).last().simulate('click');
expect(defaultProps.onEditClick).not.toHaveBeenCalled();
expect(defaultProps.configureCasesNavigation.onClick).toHaveBeenCalled();
});
});

View file

@ -11,12 +11,11 @@ import React, { memo, useCallback, useMemo } from 'react';
import { CallOut } from './callout';
import { ErrorMessage } from './types';
import { createCalloutId } from './helpers';
import { CasesNavigation } from '../../links';
import { useConfigureCasesNavigation } from '../../../common/navigation';
export * from './helpers';
export interface CaseCallOutProps {
configureCasesNavigation: CasesNavigation;
hasConnectors: boolean;
hasLicenseError: boolean;
messages?: ErrorMessage[];
@ -30,23 +29,24 @@ type GroupByTypeMessages = {
};
};
const CaseCallOutComponent = ({
configureCasesNavigation,
hasConnectors,
hasLicenseError,
onEditClick,
messages = [],
}: CaseCallOutProps) => {
const { navigateToConfigureCases } = useConfigureCasesNavigation();
const handleCallOut = useCallback(
(e) => {
e.preventDefault();
// if theres connectors open dropdown editor
// if no connectors, redirect to create case page
if (hasConnectors) {
onEditClick();
} else {
configureCasesNavigation.onClick(e);
navigateToConfigureCases();
}
},
[hasConnectors, onEditClick, configureCasesNavigation]
[hasConnectors, onEditClick, navigateToConfigureCases]
);
const groupedByTypeErrorMessages = useMemo(

View file

@ -20,20 +20,10 @@ import { connectorsMock } from '../../containers/configure/mock';
import { CLOSED_CASE_PUSH_ERROR_ID } from './callout/types';
import * as i18n from './translations';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
useHistory: jest.fn(),
}),
};
});
jest.mock('../../containers/use_get_action_license');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../../containers/configure/api');
jest.mock('../../common/navigation/hooks');
describe('usePushToService', () => {
const caseId = '12345';
@ -81,7 +71,7 @@ describe('usePushToService', () => {
};
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
(usePostPushToService as jest.Mock).mockImplementation(() => mockPostPush);
(useGetActionLicense as jest.Mock).mockImplementation(() => ({
isLoading: false,

View file

@ -21,14 +21,12 @@ import {
import * as i18n from './translations';
import { Case, CaseConnector, ActionConnector, CaseStatuses } from '../../../common';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { CasesNavigation } from '../links';
import { ErrorMessage } from './callout/types';
export interface UsePushToService {
caseId: string;
caseServices: CaseServices;
caseStatus: string;
configureCasesNavigation: CasesNavigation;
connector: CaseConnector;
connectors: ActionConnector[];
hasDataToPush: boolean;
@ -47,7 +45,6 @@ export const usePushToService = ({
caseId,
caseServices,
caseStatus,
configureCasesNavigation,
connector,
connectors,
hasDataToPush,
@ -175,7 +172,6 @@ export const usePushToService = ({
pushCallouts:
errorsMsg.length > 0 ? (
<CaseCallOut
configureCasesNavigation={configureCasesNavigation}
hasConnectors={connectors.length > 0}
hasLicenseError={hasLicenseError}
messages={errorsMsg}
@ -184,7 +180,6 @@ export const usePushToService = ({
) : null,
}),
[
configureCasesNavigation,
connector.name,
connectors.length,
errorsMsg,

View file

@ -190,12 +190,10 @@ const getUpdateActionIcon = (actionField: string): string => {
export const getUpdateAction = ({
action,
getCaseDetailHrefWithCommentId,
label,
handleOutlineComment,
}: {
action: CaseUserActions;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
label: string | JSX.Element;
handleOutlineComment: (id: string) => void;
}): EuiCommentProps => ({
@ -213,10 +211,7 @@ export const getUpdateAction = ({
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
id={action.actionId}
/>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
{action.action === 'update' && action.commentId != null && (
<EuiFlexItem grow={false}>
@ -230,7 +225,6 @@ export const getUpdateAction = ({
export const getAlertAttachment = ({
action,
alertId,
getCaseDetailHrefWithCommentId,
getRuleDetailsHref,
index,
loadingAlertData,
@ -241,7 +235,6 @@ export const getAlertAttachment = ({
}: {
action: CaseUserActions;
alertId: string;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
getRuleDetailsHref: RuleDetailsNavigation['href'];
index: string;
loadingAlertData: boolean;
@ -275,10 +268,7 @@ export const getAlertAttachment = ({
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink
id={action.actionId}
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
/>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserActionShowAlert
@ -330,7 +320,6 @@ export const toStringArray = (value: unknown): string[] => {
export const getGeneratedAlertsAttachment = ({
action,
alertIds,
getCaseDetailHrefWithCommentId,
getRuleDetailsHref,
onRuleDetailsClick,
renderInvestigateInTimelineActionComponent,
@ -339,7 +328,6 @@ export const getGeneratedAlertsAttachment = ({
}: {
action: CaseUserActions;
alertIds: string[];
getCaseDetailHrefWithCommentId: (commentId: string) => string;
getRuleDetailsHref: RuleDetailsNavigation['href'];
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
@ -366,10 +354,7 @@ export const getGeneratedAlertsAttachment = ({
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
id={action.actionId}
/>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
{renderInvestigateInTimelineActionComponent ? (
<EuiFlexItem grow={false}>
@ -402,14 +387,12 @@ export const getActionAttachment = ({
comment,
userCanCrud,
isLoadingIds,
getCaseDetailHrefWithCommentId,
actionsNavigation,
action,
}: {
comment: Comment & CommentRequestActionsType;
userCanCrud: boolean;
isLoadingIds: string[];
getCaseDetailHrefWithCommentId: (commentId: string) => string;
actionsNavigation?: ActionsNavigation;
action: CaseUserActions;
}): EuiCommentProps => ({
@ -431,12 +414,7 @@ export const getActionAttachment = ({
'data-test-subj': 'endpoint-action',
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
timelineIcon: <ActionIcon actionType={comment.actions.type} />,
actions: (
<UserActionCopyLink
id={comment.id}
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
/>
),
actions: <UserActionCopyLink id={comment.id} />,
children: comment.comment.trim().length > 0 && (
<ContentWrapper data-test-subj="user-action-markdown">
<MarkdownRenderer>{comment.comment}</MarkdownRenderer>

View file

@ -33,7 +33,6 @@ const defaultProps = {
caseServices: {},
caseUserActions: [],
connectors: [],
getCaseDetailHrefWithCommentId: jest.fn(),
actionsNavigation: { href: jest.fn(), onClick: jest.fn() },
getRuleDetailsHref: jest.fn(),
onRuleDetailsClick: jest.fn(),

View file

@ -17,7 +17,6 @@ import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils/technical
import classNames from 'classnames';
import { get, isEmpty } from 'lodash';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { isRight } from 'fp-ts/Either';
@ -58,6 +57,7 @@ import { UserActionUsername } from './user_action_username';
import { UserActionContentToolbar } from './user_action_content_toolbar';
import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers';
import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment';
import { useCaseViewParams } from '../../common/navigation';
export interface UserActionTreeProps {
caseServices: CaseServices;
@ -65,7 +65,6 @@ export interface UserActionTreeProps {
connectors: ActionConnector[];
data: Case;
fetchUserActions: () => void;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
getRuleDetailsHref?: RuleDetailsNavigation['href'];
actionsNavigation?: ActionsNavigation;
isLoadingDescription: boolean;
@ -149,7 +148,6 @@ export const UserActionTree = React.memo(
connectors,
data: caseData,
fetchUserActions,
getCaseDetailHrefWithCommentId,
getRuleDetailsHref,
actionsNavigation,
isLoadingDescription,
@ -163,15 +161,7 @@ export const UserActionTree = React.memo(
useFetchAlertData,
userCanCrud,
}: UserActionTreeProps) => {
const {
detailName: caseId,
commentId,
subCaseId,
} = useParams<{
detailName: string;
commentId?: string;
subCaseId?: string;
}>();
const { detailName: caseId, subCaseId, commentId } = useCaseViewParams();
const handlerTimeoutId = useRef(0);
const [initLoading, setInitLoading] = useState(true);
const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState('');
@ -325,7 +315,6 @@ export const UserActionTree = React.memo(
actions: (
<UserActionContentToolbar
commentMarkdown={caseData.description}
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
id={DESCRIPTION_ID}
editLabel={i18n.EDIT_DESCRIPTION}
quoteLabel={i18n.QUOTE}
@ -339,7 +328,6 @@ export const UserActionTree = React.memo(
[
MarkdownDescription,
caseData,
getCaseDetailHrefWithCommentId,
handleManageMarkdownEditId,
handleManageQuote,
isLoadingDescription,
@ -403,7 +391,6 @@ export const UserActionTree = React.memo(
),
actions: (
<UserActionContentToolbar
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
id={comment.id}
commentMarkdown={comment.comment}
editLabel={i18n.EDIT_COMMENT}
@ -456,7 +443,6 @@ export const UserActionTree = React.memo(
getAlertAttachment({
action,
alertId,
getCaseDetailHrefWithCommentId,
getRuleDetailsHref,
index: alertIndex,
loadingAlertData,
@ -485,7 +471,6 @@ export const UserActionTree = React.memo(
getGeneratedAlertsAttachment({
action,
alertIds,
getCaseDetailHrefWithCommentId,
getRuleDetailsHref,
onRuleDetailsClick,
renderInvestigateInTimelineActionComponent,
@ -508,7 +493,6 @@ export const UserActionTree = React.memo(
comment,
userCanCrud,
isLoadingIds,
getCaseDetailHrefWithCommentId,
actionsNavigation,
action,
}),
@ -526,7 +510,6 @@ export const UserActionTree = React.memo(
getUpdateAction({
action,
label,
getCaseDetailHrefWithCommentId,
handleOutlineComment,
}),
];
@ -589,7 +572,6 @@ export const UserActionTree = React.memo(
getUpdateAction({
action,
label,
getCaseDetailHrefWithCommentId,
handleOutlineComment,
}),
...footers,
@ -612,7 +594,6 @@ export const UserActionTree = React.memo(
getUpdateAction({
action,
label,
getCaseDetailHrefWithCommentId,
handleOutlineComment,
}),
];
@ -630,7 +611,6 @@ export const UserActionTree = React.memo(
manageMarkdownEditIds,
handleManageMarkdownEditId,
handleSaveComment,
getCaseDetailHrefWithCommentId,
actionsNavigation,
userCanCrud,
isLoadingIds,

View file

@ -15,7 +15,6 @@ import { CommentType } from '../../../common';
const props = {
alertId: 'alert-id-1',
getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('someCaseDetail-withcomment'),
getRuleDetailsHref: jest.fn().mockReturnValue('some-detection-rule-link'),
onRuleDetailsClick: jest.fn(),
ruleId: 'rule-id-1',

View file

@ -12,20 +12,11 @@ import {
UserActionContentToolbarProps,
} from './user_action_content_toolbar';
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useParams: jest.fn().mockReturnValue({ detailName: 'case-1' }),
};
});
jest.mock('../../common/navigation/hooks');
jest.mock('../../common/lib/kibana');
const props: UserActionContentToolbarProps = {
commentMarkdown: '',
getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'),
id: '1',
editLabel: 'edit',
quoteLabel: 'quote',

View file

@ -14,7 +14,6 @@ import { UserActionPropertyActions } from './user_action_property_actions';
export interface UserActionContentToolbarProps {
commentMarkdown: string;
id: string;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
editLabel: string;
quoteLabel: string;
isLoading: boolean;
@ -26,7 +25,6 @@ export interface UserActionContentToolbarProps {
const UserActionContentToolbarComponent = ({
commentMarkdown,
id,
getCaseDetailHrefWithCommentId,
editLabel,
quoteLabel,
isLoading,
@ -36,7 +34,7 @@ const UserActionContentToolbarComponent = ({
}: UserActionContentToolbarProps) => (
<EuiFlexGroup responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<UserActionCopyLink id={id} getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} />
<UserActionCopyLink id={id} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserActionPropertyActions

View file

@ -9,57 +9,45 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { useParams } from 'react-router-dom';
import copy from 'copy-to-clipboard';
import { useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import { UserActionCopyLink } from './user_action_copy_link';
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useParams: jest.fn(),
};
});
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../common/navigation/hooks');
jest.mock('copy-to-clipboard', () => jest.fn());
jest.mock('../../common/lib/kibana');
const mockGetUrlForApp = jest.fn(
(appId: string, options?: { path?: string; absolute?: boolean }) =>
`${appId}${options?.path ?? ''}`
);
jest.mock('../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
application: {
getUrlForApp: mockGetUrlForApp,
},
},
}),
}));
const props = {
id: 'comment-id',
getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('random-url'),
};
describe('UserActionCopyLink ', () => {
let wrapper: ReactWrapper;
beforeAll(() => {
(useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' });
wrapper = mount(<UserActionCopyLink {...props} />, { wrappingComponent: TestProviders });
});
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.application.getUrlForApp = mockGetUrlForApp;
});
it('it renders', async () => {
expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy();
});
it('calls copy clipboard correctly', async () => {
wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click');
expect(copy).toHaveBeenCalledWith('random-url');
expect(copy).toHaveBeenCalledWith('/app/security/cases/test');
});
});

View file

@ -10,19 +10,19 @@ import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import copy from 'copy-to-clipboard';
import * as i18n from './translations';
import { useCaseViewNavigation, useCaseViewParams } from '../../common/navigation';
interface UserActionCopyLinkProps {
id: string;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
}
const UserActionCopyLinkComponent = ({
id: commentId,
getCaseDetailHrefWithCommentId,
}: UserActionCopyLinkProps) => {
const UserActionCopyLinkComponent = ({ id: commentId }: UserActionCopyLinkProps) => {
const { getCaseViewUrl } = useCaseViewNavigation();
const { detailName, subCaseId } = useCaseViewParams();
const handleAnchorLink = useCallback(() => {
copy(getCaseDetailHrefWithCommentId(commentId));
}, [getCaseDetailHrefWithCommentId, commentId]);
copy(getCaseViewUrl({ detailName, subCaseId, commentId }, true));
}, [detailName, subCaseId, commentId, getCaseViewUrl]);
return (
<EuiToolTip position="top" content={<p>{i18n.COPY_REFERENCE_LINK}</p>}>

View file

@ -50,14 +50,11 @@ describe('useConfigure', () => {
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(
() => useCaseConfigure(),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
await waitForNextUpdate();
const { result } = renderHook<string, ReturnUseCaseConfigure>(() => useCaseConfigure(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await act(async () =>
expect(result.current).toEqual({
...initialState,
refetchCaseConfigure: result.current.refetchCaseConfigure,
@ -66,8 +63,8 @@ describe('useConfigure', () => {
setConnector: result.current.setConnector,
setClosureType: result.current.setClosureType,
setMappings: result.current.setMappings,
});
});
})
);
});
test('fetch case configuration', async () => {
@ -79,7 +76,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
...initialState,
closureType: caseConfigurationCamelCaseResponseMock.closureType,
@ -114,7 +110,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchCaseConfigure();
expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2);
});
@ -129,7 +124,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current.mappings).toEqual([]);
result.current.setMappings(mappings);
expect(result.current.mappings).toEqual(mappings);
@ -145,7 +139,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchCaseConfigure();
expect(result.current.loading).toBe(true);
@ -161,7 +154,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.persistCaseConfigure(configuration);
expect(result.current.persistLoading).toBeTruthy();
});
@ -193,7 +185,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(mockErrorToast).not.toHaveBeenCalled();
result.current.persistCaseConfigure(configuration);
@ -222,7 +213,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(mockErrorToast).toHaveBeenCalled();
});
});
@ -254,7 +244,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(mockErrorToast).not.toHaveBeenCalled();
result.current.persistCaseConfigure(configuration);
@ -281,7 +270,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.persistCaseConfigure(configuration);
@ -305,7 +293,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
@ -344,7 +331,6 @@ describe('useConfigure', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.persistCaseConfigure(configuration);

View file

@ -12,7 +12,7 @@ import * as i18n from './translations';
import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types';
import { ConnectorTypes } from '../../../common';
import { useToasts } from '../../common/lib/kibana';
import { useOwnerContext } from '../../components/owner_context/use_owner_context';
import { useCasesContext } from '../../components/cases_context/use_cases_context';
export type ConnectorConfiguration = { connector: CaseConnector } & {
closureType: CaseConfigure['closureType'];
@ -156,7 +156,7 @@ export const initialState: State = {
};
export const useCaseConfigure = (): ReturnUseCaseConfigure => {
const owner = useOwnerContext();
const { owner } = useCasesContext();
const [state, dispatch] = useReducer(configureCasesReducer, initialState);
const toasts = useToasts();
const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => {

View file

@ -25,24 +25,23 @@ jest.mock('../common/lib/kibana');
describe('useGetCases', () => {
const abortCtrl = new AbortController();
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
const { result } = renderHook<string, UseGetCases>(() => useGetCases(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await act(async () => {
expect(result.current).toEqual({
data: initialData,
dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty,
filterOptions: DEFAULT_FILTER_OPTIONS,
isError: false,
loading: [],
loading: ['cases'],
queryParams: DEFAULT_QUERY_PARAMS,
refetchCases: result.current.refetchCases,
selectedCases: [],
@ -60,7 +59,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] },
queryParams: DEFAULT_QUERY_PARAMS,
@ -75,7 +73,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
data: allCases,
dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty,
@ -106,7 +103,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.dispatchUpdateCaseProperty(updateCase);
expect(result.current.loading).toEqual(['caseUpdate']);
expect(spyOnPatchCase).toBeCalledWith(
@ -125,7 +121,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchCases();
expect(spyOnGetCases).toHaveBeenCalledTimes(2);
});
@ -137,7 +132,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchCases();
expect(result.current.loading).toEqual(['cases']);
@ -155,7 +149,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
data: initialData,
@ -186,7 +179,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.setFilters(newFilters);
await waitForNextUpdate();
@ -214,7 +206,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.setQueryParams(newQueryParams);
await waitForNextUpdate();
@ -237,7 +228,6 @@ describe('useGetCases', () => {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
await waitForNextUpdate();
result.current.setSelectedCases(selectedCases);
expect(result.current.selectedCases).toEqual(selectedCases);
});

View file

@ -19,7 +19,7 @@ import {
import { useToasts } from '../common/lib/kibana';
import * as i18n from './translations';
import { getCases, patchCase } from './api';
import { useOwnerContext } from '../components/owner_context/use_owner_context';
import { useCasesContext } from '../components/cases_context/use_cases_context';
export interface UseGetCasesState {
data: AllCases;
@ -145,7 +145,7 @@ export const useGetCases = (
initialFilterOptions?: Partial<FilterOptions>;
} = {}
): UseGetCases => {
const owner = useOwnerContext();
const { owner } = useCasesContext();
const { initialQueryParams = empty, initialFilterOptions = empty } = params;
const [state, dispatch] = useReducer(dataFetchReducer, {
data: initialData,

View file

@ -24,14 +24,11 @@ describe('useGetCasesStatus', () => {
});
it('init', async () => {
const { result } = renderHook<string, UseGetCasesStatus>(() => useGetCasesStatus(), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCasesStatus>(
() => useGetCasesStatus(),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current).toEqual({
countClosedCases: null,
countOpenCases: null,
@ -53,7 +50,6 @@ describe('useGetCasesStatus', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnGetCasesStatus).toBeCalledWith(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
});
});
@ -67,7 +63,6 @@ describe('useGetCasesStatus', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
countClosedCases: casesStatus.countClosedCases,
countOpenCases: casesStatus.countOpenCases,
@ -93,7 +88,6 @@ describe('useGetCasesStatus', () => {
}
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
countClosedCases: 0,

View file

@ -7,7 +7,7 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import { useOwnerContext } from '../components/owner_context/use_owner_context';
import { useCasesContext } from '../components/cases_context/use_cases_context';
import { getCasesStatus } from './api';
import * as i18n from './translations';
import { CasesStatus } from './types';
@ -31,7 +31,7 @@ export interface UseGetCasesStatus extends CasesStatusState {
}
export const useGetCasesStatus = (): UseGetCasesStatus => {
const owner = useOwnerContext();
const { owner } = useCasesContext();
const [casesStatusState, setCasesStatusState] = useState<CasesStatusState>(initialData);
const toasts = useToasts();
const isCancelledRef = useRef(false);

Some files were not shown because too many files have changed in this diff Show more