[Security Solution] [Cases] Cases UI Plugin for RAC (#97646)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
Co-authored-by: Michael Olorunnisola <michael.olorunnisola@elastic.co>
Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: David Sánchez <davidsansol92@gmail.com>
Co-authored-by: Spencer <email@spalger.com>
Co-authored-by: Dmitry <dzmitry.lemechko@elastic.co>
This commit is contained in:
Steph Milovic 2021-04-29 05:41:46 -06:00 committed by GitHub
parent 1d5fa6f53a
commit 2d5ff8ab70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
535 changed files with 10955 additions and 5155 deletions

View file

@ -338,7 +338,7 @@ Failure to have auth enabled in Kibana will make for a broken UI. UI-based error
|{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases]
|Experimental Feature
|Case management in Kibana
|{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud]

View file

@ -69,7 +69,7 @@ pageLoadAssetSize:
searchprofiler: 67080
security: 189428
securityOss: 30806
securitySolution: 235402
securitySolution: 187863
share: 99061
snapshotRestore: 79032
spaces: 387915
@ -110,3 +110,4 @@ pageLoadAssetSize:
banners: 17946
mapsEms: 26072
timelines: 28613
cases: 162385

View file

@ -16,7 +16,6 @@
"x-pack/mocks.ts",
"x-pack/typings/**/*",
"x-pack/tasks/**/*",
"x-pack/plugins/cases/**/*",
"x-pack/plugins/lists/**/*",
"x-pack/plugins/security_solution/**/*",
],
@ -84,6 +83,7 @@
{ "path": "./x-pack/plugins/apm/tsconfig.json" },
{ "path": "./x-pack/plugins/beats_management/tsconfig.json" },
{ "path": "./x-pack/plugins/canvas/tsconfig.json" },
{ "path": "./x-pack/plugins/cases/tsconfig.json" },
{ "path": "./x-pack/plugins/cloud/tsconfig.json" },
{ "path": "./x-pack/plugins/console_extensions/tsconfig.json" },
{ "path": "./x-pack/plugins/data_enhanced/tsconfig.json" },

View file

@ -60,6 +60,7 @@
{ "path": "./x-pack/plugins/apm/tsconfig.json" },
{ "path": "./x-pack/plugins/beats_management/tsconfig.json" },
{ "path": "./x-pack/plugins/canvas/tsconfig.json" },
{ "path": "./x-pack/plugins/cases/tsconfig.json" },
{ "path": "./x-pack/plugins/cloud/tsconfig.json" },
{ "path": "./x-pack/plugins/console_extensions/tsconfig.json" },
{ "path": "./x-pack/plugins/dashboard_enhanced/tsconfig.json" },

View file

@ -1,18 +1,160 @@
# Case Workflow
Case management in Kibana
*Experimental Feature*
[![Issues][issues-shield]][issues-url]
[![Pull Requests][pr-shield]][pr-url]
Elastic is developing a Case Management Workflow. Follow our progress:
# Cases Plugin Docs
- [Case API Documentation](https://www.elastic.co/guide/en/security/master/cases-overview.html)
![Cases Logo][cases-logo]
[Report Bug](https://github.com/elastic/kibana/issues/new?assignees=&labels=bug&template=Bug_report.md)
·
[Request Feature](https://github.com/elastic/kibana/issues/new?assignees=&labels=&template=Feature_request.md)
## Table of Contents
- [Cases API](#cases-api)
- [Cases UI](#cases-ui)
- [Case Action Type](#case-action-type) _feature in development, disabled by default_
# Action types
## Cases API
[**Explore the API docs »**](https://www.elastic.co/guide/en/security/current/cases-api-overview.html)
## Cases UI
#### Embed Cases UI components in any Kibana plugin
- Add `CasesUiStart` to Kibana plugin `StartServices` dependencies:
```ts
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,
timelineIntegration?: {
plugins: {
parsingPlugin,
processingPluginRenderer,
uiPlugin,
},
hooks: {
useInsertTimeline,
},
},
})
```
##### Methods:
### `getAllCases`
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
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
|disabledStatuses?|`CaseStatuses[];` array of disabled 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
UI component:
![All Cases Selector Modal Component][all-cases-modal-img]
### `getCaseView`
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
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]
### `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
UI component:
![Recent Cases Component][recent-cases-img]
## Case Action Type
_***Feature in development, disabled by default**_
See [Kibana Actions](https://github.com/elastic/kibana/tree/master/x-pack/plugins/actions) for more information.
## Case
ID: `.case`
@ -101,4 +243,24 @@ For IBM Resilient connectors:
| Property | Description | Type |
| ---------- | ------------------------------ | ------- |
| syncAlerts | Turn on or off alert synching. | boolean |
| 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/elangosundar/awesome-README-templates?style=for-the-badge
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+
[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge
[issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases
[cases-logo]: images/logo.png
[configure-img]: images/configure.png
[create-img]: images/create.png
[all-cases-img]: images/all_cases.png
[all-cases-modal-img]: images/all_cases_selector_modal.png
[recent-cases-img]: images/recent_cases.png
[case-view-img]: images/case_view.png

View file

@ -7,6 +7,7 @@
export * from './cases';
export * from './connectors';
export * from './helpers';
export * from './runtime_types';
export * from './saved_object';
export * from './user';

View file

@ -25,7 +25,13 @@ export const formatErrors = (errors: rt.Errors): string[] => {
.map((entry) => entry.key)
.join(',');
const nameContext = error.context.find((entry) => entry.type?.name?.length > 0);
const nameContext = error.context.find((entry) => {
// TODO: Put in fix for optional chaining https://github.com/cypress-io/cypress/issues/9298
if (entry.type && entry.type.name) {
return entry.type.name.length > 0;
}
return false;
});
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const DEFAULT_DATE_FORMAT = 'dateFormat';
export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz';
export const APP_ID = 'cases';
@ -50,11 +52,8 @@ export const SUPPORTED_CONNECTORS = [
/**
* Alerts
*/
// this value is from x-pack/plugins/security_solution/common/constants.ts
const DEFAULT_MAX_SIGNALS = 100;
export const MAX_ALERTS_PER_SUB_CASE = 5000;
export const MAX_GENERATED_ALERTS_PER_SUB_CASE = MAX_ALERTS_PER_SUB_CASE / DEFAULT_MAX_SIGNALS;
export const MAX_GENERATED_ALERTS_PER_SUB_CASE = 50;
/**
* This flag governs enabling the case as a connector feature. It is disabled by default as the feature is not complete.

View file

@ -0,0 +1,10 @@
/*
* 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 * from './constants';
export * from './api';
export * from './ui/types';

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export * from '../../translations';
export * from './types';

View file

@ -6,20 +6,22 @@
*/
import {
User,
UserActionField,
UserAction,
CaseConnector,
CommentRequest,
CaseStatuses,
CaseAttributes,
CasePatchRequest,
CaseType,
AssociationType,
} from '../../../../cases/common/api';
import { CaseStatusWithAllStatus } from '../components/status';
CaseAttributes,
CaseConnector,
CasePatchRequest,
CaseStatuses,
CaseType,
CommentRequest,
User,
UserAction,
UserActionField,
} from '../api';
export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../cases/common/api';
export const StatusAll = 'all' as const;
export type StatusAllType = typeof StatusAll;
export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType;
export type Comment = CommentRequest & {
associationType: AssociationType;
@ -172,3 +174,56 @@ export interface UpdateByKey {
onSuccess?: () => void;
onError?: () => void;
}
export interface RuleEcs {
id?: string[];
rule_id?: string[];
name?: string[];
false_positives: string[];
saved_id?: string[];
timeline_id?: string[];
timeline_title?: string[];
max_signals?: number[];
risk_score?: string[];
output_index?: string[];
description?: string[];
from?: string[];
immutable?: boolean[];
index?: string[];
interval?: string[];
language?: string[];
query?: string[];
references?: string[];
severity?: string[];
tags?: string[];
threat?: unknown;
threshold?: unknown;
type?: string[];
size?: string[];
to?: string[];
enabled?: boolean[];
filters?: unknown;
created_at?: string[];
updated_at?: string[];
created_by?: string[];
updated_by?: string[];
version?: string[];
note?: string[];
building_block_type?: string[];
}
export interface SignalEcs {
rule?: RuleEcs;
original_time?: string[];
status?: string[];
group?: {
id?: string[];
};
threshold_result?: unknown;
}
export interface Ecs {
_id: string;
_index?: string;
signal?: SignalEcs;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -2,12 +2,13 @@
"configPath": ["xpack", "cases"],
"id": "cases",
"kibanaVersion": "kibana",
"requiredPlugins": ["actions", "securitySolution"],
"extraPublicDirs": ["common"],
"requiredPlugins": ["actions", "esUiShared", "kibanaReact", "kibanaUtils", "triggersActionsUi"],
"optionalPlugins": [
"spaces",
"security"
],
"server": true,
"ui": false,
"ui": true,
"version": "8.0.0"
}

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 { has } from 'lodash/fp';
export interface AppError {
name: string;
message: string;
body: {
message: string;
};
}
export interface KibanaError extends AppError {
body: {
message: string;
statusCode: number;
};
}
export interface CasesAppError extends AppError {
body: {
message: string;
status_code: number;
};
}
export const isKibanaError = (error: unknown): error is KibanaError =>
has('message', error) && has('body.message', error) && has('body.statusCode', error);
export const isCasesAppError = (error: unknown): error is CasesAppError =>
has('message', error) && has('body.message', error) && has('body.status_code', error);
export const isAppError = (error: unknown): error is AppError =>
isKibanaError(error) || isCasesAppError(error);

View file

@ -0,0 +1,30 @@
/*
* 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 { notificationServiceMock } from '../../../../../../../../src/core/public/mocks';
import {
createKibanaContextProviderMock,
createStartServicesMock,
createWithKibanaMock,
} from '../kibana_react.mock';
export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') };
export const useKibana = jest.fn().mockReturnValue({
services: createStartServicesMock(),
});
export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http);
export const useTimeZone = jest.fn();
export const useDateFormat = jest.fn();
export const useBasePath = jest.fn(() => '/test/base/path');
export const useToasts = jest
.fn()
.mockReturnValue(notificationServiceMock.createStartContract().toasts);
export const useCurrentUser = jest.fn();
export const withKibana = jest.fn(createWithKibanaMock());
export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock());
export const useGetUserSavedObjectPermissions = jest.fn();

View file

@ -0,0 +1,132 @@
/*
* 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 moment from 'moment-timezone';
import { useCallback, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants';
import { AuthenticatedUser } from '../../../../../security/common/model';
import { convertToCamelCase } from '../../../containers/utils';
import { StartServices } from '../../../types';
import { useUiSetting, useKibana } from './kibana_react';
export const useDateFormat = (): string => useUiSetting<string>(DEFAULT_DATE_FORMAT);
export const useTimeZone = (): string => {
const timeZone = useUiSetting<string>(DEFAULT_DATE_FORMAT_TZ);
return timeZone === 'Browser' ? moment.tz.guess() : timeZone;
};
export const useBasePath = (): string => useKibana().services.http.basePath.get();
export const useToasts = (): StartServices['notifications']['toasts'] =>
useKibana().services.notifications.toasts;
export const useHttp = (): StartServices['http'] => useKibana().services.http;
interface UserRealm {
name: string;
type: string;
}
export interface AuthenticatedElasticUser {
username: string;
email: string;
fullName: string;
roles: string[];
enabled: boolean;
metadata?: {
_reserved: boolean;
};
authenticationRealm: UserRealm;
lookupRealm: UserRealm;
authenticationProvider: string;
}
export const useCurrentUser = (): AuthenticatedElasticUser | null => {
const [user, setUser] = useState<AuthenticatedElasticUser | null>(null);
const toasts = useToasts();
const { security } = useKibana().services;
const fetchUser = useCallback(() => {
let didCancel = false;
const fetchData = async () => {
try {
if (security != null) {
const response = await security.authc.getCurrentUser();
if (!didCancel) {
setUser(convertToCamelCase<AuthenticatedUser, AuthenticatedElasticUser>(response));
}
} else {
setUser({
username: i18n.translate('xpack.cases.getCurrentUser.unknownUser', {
defaultMessage: 'Unknown',
}),
email: '',
fullName: '',
roles: [],
enabled: false,
authenticationRealm: { name: '', type: '' },
lookupRealm: { name: '', type: '' },
authenticationProvider: '',
});
}
} catch (error) {
if (!didCancel) {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.translate('xpack.cases.getCurrentUser.Error', {
defaultMessage: 'Error getting user',
}),
}
);
setUser(null);
}
}
};
fetchData();
return () => {
didCancel = true;
};
}, [security, toasts]);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return user;
};
export interface UseGetUserSavedObjectPermissions {
crud: boolean;
read: boolean;
}
export const useGetUserSavedObjectPermissions = () => {
const [
savedObjectsPermissions,
setSavedObjectsPermissions,
] = useState<UseGetUserSavedObjectPermissions | null>(null);
const uiCapabilities = useKibana().services.application.capabilities;
useEffect(() => {
const capabilitiesCanUserCRUD: boolean =
typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false;
const capabilitiesCanUserRead: boolean =
typeof uiCapabilities.siem.show === 'boolean' ? uiCapabilities.siem.show : false;
setSavedObjectsPermissions({
crud: capabilitiesCanUserCRUD,
read: capabilitiesCanUserRead,
});
}, [uiCapabilities]);
return savedObjectsPermissions;
};

View file

@ -0,0 +1,10 @@
/*
* 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 * from './hooks';
export * from './kibana_react';
export * from './services';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { RecursivePartial } from '@elastic/eui/src/components/common';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../../types';
import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common';
export const createStartServicesMock = (): StartServices =>
(coreMock.createStart() as unknown) as StartServices;
export const createWithKibanaMock = () => {
const services = createStartServicesMock();
return (Component: unknown) => (props: unknown) => {
return React.createElement(Component as string, { ...(props as object), kibana: { services } });
};
};
export const createKibanaContextProviderMock = () => {
const services = createStartServicesMock();
return ({ children }: { children: React.ReactNode }) =>
React.createElement(KibanaContextProvider, { services }, children);
};
export const getMockTheme = (partialTheme: RecursivePartial<EuiTheme>): EuiTheme =>
partialTheme as EuiTheme;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
KibanaContextProvider,
useKibana,
useUiSetting,
useUiSetting$,
} from '../../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../../types';
const useTypedKibana = () => useKibana<StartServices>();
export { KibanaContextProvider, useTypedKibana as useKibana, useUiSetting, useUiSetting$ };

View file

@ -0,0 +1,42 @@
/*
* 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 { CoreStart } from 'kibana/public';
type GlobalServices = Pick<CoreStart, 'http'>;
export class KibanaServices {
private static kibanaVersion?: string;
private static services?: GlobalServices;
public static init({ http, kibanaVersion }: GlobalServices & { kibanaVersion: string }) {
this.services = { http };
this.kibanaVersion = kibanaVersion;
}
public static get(): GlobalServices {
if (!this.services) {
this.throwUninitializedError();
}
return this.services;
}
public static getKibanaVersion(): string {
if (!this.kibanaVersion) {
this.throwUninitializedError();
}
return this.kibanaVersion;
}
private static throwUninitializedError(): never {
throw new Error(
'Kibana services not initialized - are you trying to import this module from outside of the Cases app?'
);
}
}

View file

@ -0,0 +1,8 @@
/*
* 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 * from './test_providers';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
window.matchMedia = jest.fn().mockImplementation((query) => {
return {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
};
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { I18nProvider } from '@kbn/i18n/react';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { ThemeProvider } from 'styled-components';
import {
createKibanaContextProviderMock,
createStartServicesMock,
} from '../lib/kibana/kibana_react.mock';
import { FieldHook } from '../shared_imports';
interface Props {
children: React.ReactNode;
}
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 }) => (
<I18nProvider>
<MockKibanaContextProvider>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>{children}</ThemeProvider>
</MockKibanaContextProvider>
</I18nProvider>
);
export const TestProviders = React.memo(TestProvidersComponent);
export const useFormFieldMock = <T,>(options?: Partial<FieldHook<T>>): FieldHook<T> => {
return {
path: 'path',
type: 'type',
value: ('mockedValue' as unknown) as T,
isPristine: false,
isValidating: false,
isValidated: false,
isChangingValue: false,
errors: [],
isValid: true,
getErrorsMessages: jest.fn(),
onChange: jest.fn(),
setValue: jest.fn(),
setErrors: jest.fn(),
clearErrors: jest.fn(),
validate: jest.fn(),
reset: jest.fn(),
__isIncludedInOutput: true,
__serializeValue: jest.fn(),
...options,
};
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export {
getUseField,
getFieldValidityAndErrorMessage,
FieldHook,
FieldValidateResponse,
FIELD_TYPES,
Form,
FormData,
FormDataProvider,
FormHook,
FormSchema,
UseField,
UseMultiFields,
useForm,
useFormContext,
useFormData,
ValidationError,
ValidationFunc,
VALIDATION_TYPES,
} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export {
Field,
SelectField,
} from '../../../../../src/plugins/es_ui_shared/static/forms/components';
export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers';
export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types';

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
/**
* Convenience utility to remove text appended to links by EUI
*/
export const removeExternalLinkText = (str: string) =>
str.replace(/\(opens in a new tab or window\)/g, '');

View file

@ -0,0 +1,259 @@
/*
* 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 SAVED_OBJECT_NO_PERMISSIONS_TITLE = i18n.translate(
'xpack.cases.caseSavedObjectNoPermissionsTitle',
{
defaultMessage: 'Kibana feature privileges required',
}
);
export const SAVED_OBJECT_NO_PERMISSIONS_MSG = i18n.translate(
'xpack.cases.caseSavedObjectNoPermissionsMessage',
{
defaultMessage:
'To view cases, you must have privileges for the Saved Object Management feature in the Kibana space. For more information, contact your Kibana administrator.',
}
);
export const BACK_TO_ALL = i18n.translate('xpack.cases.caseView.backLabel', {
defaultMessage: 'Back to cases',
});
export const CANCEL = i18n.translate('xpack.cases.caseView.cancel', {
defaultMessage: 'Cancel',
});
export const DELETE_CASE = i18n.translate('xpack.cases.confirmDeleteCase.deleteCase', {
defaultMessage: 'Delete case',
});
export const DELETE_CASES = i18n.translate('xpack.cases.confirmDeleteCase.deleteCases', {
defaultMessage: 'Delete cases',
});
export const NAME = i18n.translate('xpack.cases.caseView.name', {
defaultMessage: 'Name',
});
export const OPENED_ON = i18n.translate('xpack.cases.caseView.openedOn', {
defaultMessage: 'Opened on',
});
export const CLOSED_ON = i18n.translate('xpack.cases.caseView.closedOn', {
defaultMessage: 'Closed on',
});
export const REPORTER = i18n.translate('xpack.cases.caseView.reporterLabel', {
defaultMessage: 'Reporter',
});
export const PARTICIPANTS = i18n.translate('xpack.cases.caseView.particpantsLabel', {
defaultMessage: 'Participants',
});
export const CREATE_BC_TITLE = i18n.translate('xpack.cases.caseView.breadcrumb', {
defaultMessage: 'Create',
});
export const CREATE_TITLE = i18n.translate('xpack.cases.caseView.create', {
defaultMessage: 'Create new case',
});
export const DESCRIPTION = i18n.translate('xpack.cases.caseView.description', {
defaultMessage: 'Description',
});
export const DESCRIPTION_REQUIRED = i18n.translate(
'xpack.cases.createCase.descriptionFieldRequiredError',
{
defaultMessage: 'A description is required.',
}
);
export const COMMENT_REQUIRED = i18n.translate('xpack.cases.caseView.commentFieldRequiredError', {
defaultMessage: 'A comment is required.',
});
export const REQUIRED_FIELD = i18n.translate('xpack.cases.caseView.fieldRequiredError', {
defaultMessage: 'Required field',
});
export const EDIT = i18n.translate('xpack.cases.caseView.edit', {
defaultMessage: 'Edit',
});
export const OPTIONAL = i18n.translate('xpack.cases.caseView.optional', {
defaultMessage: 'Optional',
});
export const PAGE_TITLE = i18n.translate('xpack.cases.pageTitle', {
defaultMessage: 'Cases',
});
export const CREATE_CASE = i18n.translate('xpack.cases.caseView.createCase', {
defaultMessage: 'Create case',
});
export const CLOSE_CASE = i18n.translate('xpack.cases.caseView.closeCase', {
defaultMessage: 'Close case',
});
export const MARK_CASE_IN_PROGRESS = i18n.translate('xpack.cases.caseView.markInProgress', {
defaultMessage: 'Mark in progress',
});
export const REOPEN_CASE = i18n.translate('xpack.cases.caseView.reopenCase', {
defaultMessage: 'Reopen case',
});
export const OPEN_CASE = i18n.translate('xpack.cases.caseView.openCase', {
defaultMessage: 'Open case',
});
export const CASE_NAME = i18n.translate('xpack.cases.caseView.caseName', {
defaultMessage: 'Case name',
});
export const TO = i18n.translate('xpack.cases.caseView.to', {
defaultMessage: 'to',
});
export const TAGS = i18n.translate('xpack.cases.caseView.tags', {
defaultMessage: 'Tags',
});
export const ACTIONS = i18n.translate('xpack.cases.allCases.actions', {
defaultMessage: 'Actions',
});
export const NO_TAGS_AVAILABLE = i18n.translate('xpack.cases.allCases.noTagsAvailable', {
defaultMessage: 'No tags available',
});
export const NO_REPORTERS_AVAILABLE = i18n.translate('xpack.cases.caseView.noReportersAvailable', {
defaultMessage: 'No reporters available.',
});
export const COMMENTS = i18n.translate('xpack.cases.allCases.comments', {
defaultMessage: 'Comments',
});
export const TAGS_HELP = i18n.translate('xpack.cases.createCase.fieldTagsHelpText', {
defaultMessage:
'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.',
});
export const NO_TAGS = i18n.translate('xpack.cases.caseView.noTags', {
defaultMessage: 'No tags are currently assigned to this case.',
});
export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldRequiredError', {
defaultMessage: 'A title is required.',
});
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', {
defaultMessage: 'Configure cases',
});
export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', {
defaultMessage: 'Edit external connection',
});
export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', {
defaultMessage: 'Add comment',
});
export const ADD_COMMENT_HELP_TEXT = i18n.translate(
'xpack.cases.caseView.comment.addCommentHelpText',
{
defaultMessage: 'Add a new comment...',
}
);
export const SAVE = i18n.translate('xpack.cases.caseView.description.save', {
defaultMessage: 'Save',
});
export const GO_TO_DOCUMENTATION = i18n.translate('xpack.cases.caseView.goToDocumentationButton', {
defaultMessage: 'View documentation',
});
export const CONNECTORS = i18n.translate('xpack.cases.caseView.connectors', {
defaultMessage: 'External Incident Management System',
});
export const EDIT_CONNECTOR = i18n.translate('xpack.cases.caseView.editConnector', {
defaultMessage: 'Change external incident management system',
});
export const NO_CONNECTOR = i18n.translate('xpack.cases.common.noConnector', {
defaultMessage: 'No connector selected',
});
export const UNKNOWN = i18n.translate('xpack.cases.caseView.unknown', {
defaultMessage: 'Unknown',
});
export const MARKED_CASE_AS = i18n.translate('xpack.cases.caseView.markedCaseAs', {
defaultMessage: 'marked case as',
});
export const OPEN_CASES = i18n.translate('xpack.cases.caseTable.openCases', {
defaultMessage: 'Open cases',
});
export const CLOSED_CASES = i18n.translate('xpack.cases.caseTable.closedCases', {
defaultMessage: 'Closed cases',
});
export const IN_PROGRESS_CASES = i18n.translate('xpack.cases.caseTable.inProgressCases', {
defaultMessage: 'In progress cases',
});
export const SYNC_ALERTS_SWITCH_LABEL_ON = i18n.translate(
'xpack.cases.settings.syncAlertsSwitchLabelOn',
{
defaultMessage: 'On',
}
);
export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate(
'xpack.cases.settings.syncAlertsSwitchLabelOff',
{
defaultMessage: 'Off',
}
);
export const SYNC_ALERTS_HELP = i18n.translate('xpack.cases.components.create.syncAlertHelpText', {
defaultMessage:
'Enabling this option will sync the status of alerts in this case with the case status.',
});
export const ALERT = i18n.translate('xpack.cases.common.alertLabel', {
defaultMessage: 'Alert',
});
export const ALERTS = i18n.translate('xpack.cases.common.alertsLabel', {
defaultMessage: 'Alerts',
});
export const ALERT_ADDED_TO_CASE = i18n.translate('xpack.cases.common.alertAddedToCase', {
defaultMessage: 'added to case',
});
export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate(
'xpack.cases.common.allCases.table.selectableMessageCollections',
{
defaultMessage: 'Cases with sub-cases cannot be selected',
}
);
export const SELECT_CASE_TITLE = i18n.translate('xpack.cases.common.allCases.caseModal.title', {
defaultMessage: 'Select case',
});

View file

@ -0,0 +1,50 @@
/*
* 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 { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form');
jest.mock(
'../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
export const mockFormHook = {
isSubmitted: false,
isSubmitting: false,
isValid: true,
submit: jest.fn(),
subscribe: jest.fn(),
setFieldValue: jest.fn(),
setFieldErrors: jest.fn(),
getFields: jest.fn(),
getFormData: jest.fn(),
/* Returns a list of all errors in the form */
getErrors: jest.fn(),
reset: jest.fn(),
__options: {},
__formData$: {},
__addField: jest.fn(),
__removeField: jest.fn(),
__validateFields: jest.fn(),
__updateFormDataAt: jest.fn(),
__readFieldConfigFromSchema: jest.fn(),
__getFieldDefaultValue: jest.fn(),
};
export const getFormMock = (sampleData: any) => ({
...mockFormHook,
submit: () =>
Promise.resolve({
data: sampleData,
isValid: true,
}),
getFormData: () => sampleData,
});
export const useFormMock = useForm as jest.Mock;
export const useFormDataMock = useFormData as jest.Mock;

View file

@ -0,0 +1,40 @@
/*
* 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 { Router } from 'react-router-dom';
// eslint-disable-next-line @kbn/eslint/module_migration
import routeData from 'react-router';
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
pathname: '/network',
search: '',
state: '',
hash: '',
};
export const mockHistory = {
length: 2,
location,
action: pop,
push: jest.fn(),
replace: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
block: jest.fn(),
createHref: jest.fn(),
listen: jest.fn(),
};
export const mockLocation = {
pathname: '/welcome',
hash: '',
search: '',
state: '',
};
export { Router, routeData };

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
jest.mock('../timeline_context');
const mockTimelineComponent = (name: string) => <span data-test-subj={name}>{name}</span>;
export const timelineIntegrationMock = {
editor_plugins: {
parsingPlugin: jest.fn(),
processingPluginRenderer: () => mockTimelineComponent('plugin-renderer'),
uiPlugin: {
name: 'mock-timeline',
button: { label: 'mock-timeline-button', iconType: 'mock-timeline-icon' },
editor: () => mockTimelineComponent('plugin-timeline-editor'),
},
},
hooks: {
useInsertTimeline: jest.fn(),
},
ui: {
renderInvestigateInTimelineActionComponent: () =>
mockTimelineComponent('investigate-in-timeline'),
renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'),
},
};
export const useTimelineContextMock = useTimelineContext as jest.Mock;

View file

@ -10,19 +10,18 @@ import { mount } from 'enzyme';
import { waitFor, act } from '@testing-library/react';
import { noop } from 'lodash/fp';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { CommentRequest, CommentType } from '../../../../../cases/common/api';
import { useInsertTimeline } from '../use_insert_timeline';
import { CommentRequest, CommentType } from '../../../common';
import { usePostComment } from '../../containers/use_post_comment';
import { AddComment, AddCommentRefObject } from '.';
import { CasesTimelineIntegrationProvider } from '../timeline_context';
import { timelineIntegrationMock } from '../__mock__/timeline';
jest.mock('../../containers/use_post_comment');
jest.mock('../use_insert_timeline');
const usePostCommentMock = usePostComment as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const onCommentSaving = jest.fn();
const onCommentPosted = jest.fn();
const postComment = jest.fn();
@ -49,7 +48,7 @@ const sampleData: CommentRequest = {
describe('AddComment ', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
usePostCommentMock.mockImplementation(() => defaultPostComment);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
});
@ -63,20 +62,15 @@ describe('AddComment ', () => {
</TestProviders>
);
await act(async () => {
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
});
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy();
await act(async () => {
wrapper.find(`[data-test-subj="submit-comment"]`).first().simulate('click');
});
wrapper.find(`[data-test-subj="submit-comment"]`).first().simulate('click');
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(postComment).toBeCalledWith({
@ -131,12 +125,10 @@ describe('AddComment ', () => {
</TestProviders>
);
await act(async () => {
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
});
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
await act(async () => {
ref.current!.addQuote(sampleQuote);
@ -148,16 +140,22 @@ describe('AddComment ', () => {
});
it('it should insert a timeline', async () => {
const useInsertTimelineMock = jest.fn();
let attachTimeline = noop;
useInsertTimelineMock.mockImplementation((comment, onTimelineAttached) => {
attachTimeline = onTimelineAttached;
});
const mockTimelineIntegration = { ...timelineIntegrationMock };
mockTimelineIntegration.hooks.useInsertTimeline = useInsertTimelineMock;
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<AddComment {...{ ...addCommentProps }} />
</Router>
<CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}>
<Router history={mockHistory}>
<AddComment {...{ ...addCommentProps }} />
</Router>
</CasesTimelineIntegrationProvider>
</TestProviders>
);

View file

@ -9,16 +9,15 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
import React, { useCallback, forwardRef, useImperativeHandle } from 'react';
import styled from 'styled-components';
import { CommentType } from '../../../../../cases/common/api';
import { CommentType } from '../../../common';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
import { MarkdownEditorForm } from '../markdown_editor';
import { Form, useForm, UseField, useFormData } from '../../common/shared_imports';
import * as i18n from './translations';
import { schema, AddCommentFormSchema } from './schema';
import { useInsertTimeline } from '../use_insert_timeline';
import { InsertTimeline } from '../insert_timeline';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
top: 50%;
@ -71,13 +70,6 @@ export const AddComment = React.memo(
addQuote,
}));
const onTimelineAttached = useCallback(
(newValue: string) => setFieldValue(fieldName, newValue),
[setFieldValue]
);
useInsertTimeline(comment ?? '', onTimelineAttached);
const onSubmit = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
@ -120,6 +112,7 @@ export const AddComment = React.memo(
),
}}
/>
<InsertTimeline fieldName="comment" />
</Form>
</span>
);

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { CommentRequestUserType } from '../../../../../cases/common/api';
import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports';
import { CommentRequestUserType } from '../../../common';
import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports';
import * as i18n from './translations';
const { emptyField } = fieldValidators;

View file

@ -0,0 +1,8 @@
/*
* 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 * from '../../common/translations';

View file

@ -8,7 +8,7 @@
import { Dispatch } from 'react';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses } from '../../../common';
import { Case, SubCase } from '../../containers/types';
import { UpdateCase } from '../../containers/use_get_cases';
import { statuses } from '../status';
@ -16,13 +16,11 @@ import * as i18n from './translations';
import { isIndividual } from './helpers';
interface GetActions {
caseStatus: string;
dispatchUpdate: Dispatch<Omit<UpdateCase, 'refetchCasesStatus'>>;
deleteCaseOnClick: (deleteCase: Case) => void;
}
export const getActions = ({
caseStatus,
dispatchUpdate,
deleteCaseOnClick,
}: GetActions): Array<DefaultItemIconButtonAction<Case>> => {

View file

@ -0,0 +1,321 @@
/*
* 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, useMemo, useRef, useState } from 'react';
import { EuiProgress } from '@elastic/eui';
import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
import { isEmpty, memoize } from 'lodash/fp';
import styled, { css } from 'styled-components';
import classnames from 'classnames';
import {
Case,
CaseStatuses,
CaseType,
CommentRequestAlertType,
CommentType,
FilterOptions,
SortFieldCase,
SubCase,
} 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 { CaseCallOut } from '../callout';
import { CaseDetailsHrefSchema, CasesNavigation } from '../links';
import { Panel } from '../panel';
import { getActionLicenseError } from '../use_push_to_service/helpers';
import { ERROR_PUSH_SERVICE_CALLOUT_TITLE } from '../use_push_to_service/translations';
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';
const ProgressLoader = styled(EuiProgress)`
${({ $isShow }: { $isShow: boolean }) =>
$isShow
? css`
top: 2px;
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
z-index: ${({ theme }) => theme.eui.euiZHeader};
`
: `
display: none;
`}
`;
const getSortField = (field: string): SortFieldCase =>
field === SortFieldCase.closedAt ? SortFieldCase.closedAt : SortFieldCase.createdAt;
interface AllCasesGenericProps {
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;
disabledStatuses?: CaseStatuses[];
isSelectorView?: boolean;
onRowClick?: (theCase?: Case | SubCase) => void;
updateCase?: (newCase: Case) => void;
userCanCrud: boolean;
}
export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
({
alertData,
caseDetailsNavigation,
configureCasesNavigation,
createCaseNavigation,
disabledStatuses,
isSelectorView,
onRowClick,
updateCase,
userCanCrud,
}) => {
const { actionLicense } = useGetActionLicense();
const {
data,
dispatchUpdateCaseProperty,
filterOptions,
loading,
queryParams,
selectedCases,
refetchCases,
setFilters,
setQueryParams,
setSelectedCases,
} = useGetCases();
// Post Comment to Case
const { postComment, isLoading: isCommentUpdating } = usePostComment();
const sorting = useMemo(
() => ({
sort: { field: queryParams.sortField, direction: queryParams.sortOrder },
}),
[queryParams.sortField, queryParams.sortOrder]
);
const filterRefetch = useRef<() => void>();
const setFilterRefetch = useCallback(
(refetchFilter: () => void) => {
filterRefetch.current = refetchFilter;
},
[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);
setSelectedCases([]);
if (filterRefetch.current != null) {
filterRefetch.current();
}
},
[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;
if (sort) {
newQueryParams = {
...newQueryParams,
sortField: getSortField(sort.field),
sortOrder: sort.direction,
};
}
if (page) {
newQueryParams = {
...newQueryParams,
page: page.index + 1,
perPage: page.size,
};
}
setQueryParams(newQueryParams);
refreshCases(false);
},
[queryParams, refreshCases, setQueryParams]
);
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial<FilterOptions>) => {
if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) {
setQueryParams({ sortField: SortFieldCase.closedAt });
} else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) {
setQueryParams({ sortField: SortFieldCase.createdAt });
} else if (
newFilterOptions.status &&
newFilterOptions.status === CaseStatuses['in-progress']
) {
setQueryParams({ sortField: SortFieldCase.createdAt });
}
setFilters(newFilterOptions);
refreshCases(false);
},
[refreshCases, setQueryParams, setFilters]
);
const showActions = userCanCrud && !isSelectorView;
const columns = useCasesColumns({
caseDetailsNavigation,
dispatchUpdateCaseProperty,
filterStatus: filterOptions.status,
handleIsLoading,
isLoadingCases: loading,
refreshCases,
showActions,
});
const itemIdToExpandedRowMap = useMemo(
() =>
getExpandedRowMap({
columns,
data: data.cases,
onSubCaseClick: onRowClick,
}),
[data.cases, columns, onRowClick]
);
const pagination = useMemo(
() => ({
pageIndex: queryParams.page - 1,
pageSize: queryParams.perPage,
totalItemCount: data.total,
pageSizeOptions: [5, 10, 15, 20, 25],
}),
[data, queryParams]
);
const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>(
() => ({
onSelectionChange: setSelectedCases,
selectableMessage: (selectable) => (!selectable ? SELECTABLE_MESSAGE_COLLECTIONS : ''),
initialSelected: selectedCases,
}),
[selectedCases, setSelectedCases]
);
const isCasesLoading = useMemo(() => loading.indexOf('cases') > -1, [loading]);
const isDataEmpty = useMemo(() => data.total === 0, [data]);
const TableWrap = useMemo(() => (isSelectorView ? 'span' : Panel), [isSelectorView]);
const tableRowProps = useCallback(
(theCase: Case) => {
const onTableRowClick = memoize(async () => {
if (alertData != null) {
await postComment({
caseId: theCase.id,
data: {
type: CommentType.alert,
...alertData,
},
updateCase,
});
}
if (onRowClick) {
onRowClick(theCase);
}
});
return {
'data-test-subj': `cases-table-row-${theCase.id}`,
className: classnames({ isDisabled: theCase.type === CaseType.collection }),
...(isSelectorView && theCase.type !== CaseType.collection
? { onClick: onTableRowClick }
: {}),
};
},
[isSelectorView, alertData, onRowClick, postComment, updateCase]
);
return (
<>
{!isEmpty(actionsErrors) && (
<CaseCallOut title={ERROR_PUSH_SERVICE_CALLOUT_TITLE} messages={actionsErrors} />
)}
{configureCasesNavigation != null && (
<CasesTableHeader
actionsErrors={actionsErrors}
createCaseNavigation={createCaseNavigation}
configureCasesNavigation={configureCasesNavigation}
refresh={refresh}
userCanCrud={userCanCrud}
/>
)}
<ProgressLoader
size="xs"
color="accent"
className="essentialAnimation"
$isShow={(isCasesLoading || isLoading || isCommentUpdating) && !isDataEmpty}
/>
<TableWrap
data-test-subj="table-wrap"
loading={!isSelectorView ? isCasesLoading : undefined}
>
<CasesTableFilters
countClosedCases={data.countClosedCases}
countOpenCases={data.countOpenCases}
countInProgressCases={data.countInProgressCases}
onFilterChanged={onFilterChangedCallback}
initial={{
search: filterOptions.search,
reporters: filterOptions.reporters,
tags: filterOptions.tags,
status: filterOptions.status,
}}
setFilterRefetch={setFilterRefetch}
disabledStatuses={disabledStatuses}
/>
<CasesTable
columns={columns}
createCaseNavigation={createCaseNavigation}
data={data}
filterOptions={filterOptions}
goToCreateCase={goToCreateCase}
handleIsLoading={handleIsLoading}
isCasesLoading={isCasesLoading}
isCommentUpdating={isCommentUpdating}
isDataEmpty={isDataEmpty}
isSelectorView={isSelectorView}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
onChange={tableOnChangeCallback}
pagination={pagination}
refreshCases={refreshCases}
selectedCases={selectedCases}
selection={euiBasicTableSelectionProps}
showActions={showActions}
sorting={sorting}
tableRowProps={tableRowProps}
userCanCrud={userCanCrud}
/>
</TableWrap>
</>
);
}
);
AllCasesGeneric.displayName = 'AllCasesGeneric';

View file

@ -8,7 +8,7 @@
import React from 'react';
import { mount } from 'enzyme';
import '../../../common/mock/match_media';
import '../../common/mock/match_media';
import { ExternalServiceColumn } from './columns';
import { useGetCasesMockState } from '../../containers/mock';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiAvatar,
EuiBadgeGroup,
@ -19,22 +19,24 @@ import {
} from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import styled from 'styled-components';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses, CaseType } from '../../../../../cases/common/api';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { Case, SubCase } from '../../containers/types';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { CaseDetailsLink } from '../../../common/components/links';
import { CaseStatuses, CaseType, DeleteCase, Case, SubCase } from '../../../common';
import { getEmptyTagValue } from '../empty_value';
import { FormattedRelativePreferenceDate } from '../formatted_date';
import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links';
import * as i18n from './translations';
import { Status } from '../status';
import { getSubCasesStatusCountsBadges, isSubCase } from './helpers';
import { ALERTS } from '../../../app/home/translations';
import { ALERTS } from '../../common/translations';
import { getActions } from './actions';
import { UpdateCase } from '../../containers/use_get_cases';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
export type CasesColumns =
| EuiTableFieldDataColumnType<Case>
| EuiTableActionsColumnType<Case>
| EuiTableComputedColumnType<Case>
| EuiTableActionsColumnType<Case>;
| EuiTableFieldDataColumnType<Case>;
const MediumShadeText = styled.p`
color: ${({ theme }) => theme.eui.euiColorMediumShade};
@ -51,27 +53,98 @@ const TagWrapper = styled(EuiBadgeGroup)`
const renderStringField = (field: string, dataTestSubj: string) =>
field != null ? <span data-test-subj={dataTestSubj}>{field}</span> : getEmptyTagValue();
export const getCasesColumns = (
actions: Array<DefaultItemIconButtonAction<Case>>,
filterStatus: string,
isModal: boolean
): CasesColumns[] => {
const columns = [
export interface GetCasesColumn {
caseDetailsNavigation?: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
dispatchUpdateCaseProperty: (u: UpdateCase) => void;
filterStatus: string;
handleIsLoading: (a: boolean) => void;
isLoadingCases: string[];
refreshCases?: (a?: boolean) => void;
showActions: boolean;
}
export const useCasesColumns = ({
caseDetailsNavigation,
dispatchUpdateCaseProperty,
filterStatus,
handleIsLoading,
isLoadingCases,
refreshCases,
showActions,
}: GetCasesColumn): CasesColumns[] => {
// Delete case
const {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isDeleted,
isDisplayConfirmDeleteModal,
isLoading: isDeleting,
} = useDeleteCases();
const [deleteThisCase, setDeleteThisCase] = useState<DeleteCase>({
id: '',
title: '',
type: null,
});
const toggleDeleteModal = useCallback(
(deleteCase: Case) => {
handleToggleModal();
setDeleteThisCase({ id: deleteCase.id, title: deleteCase.title, type: deleteCase.type });
},
[handleToggleModal]
);
const handleDispatchUpdate = useCallback(
(args: Omit<UpdateCase, 'refetchCasesStatus'>) => {
dispatchUpdateCaseProperty({
...args,
refetchCasesStatus: () => {
if (refreshCases != null) refreshCases();
},
});
},
[dispatchUpdateCaseProperty, refreshCases]
);
const actions = useMemo(
() =>
getActions({
deleteCaseOnClick: toggleDeleteModal,
dispatchUpdate: handleDispatchUpdate,
}),
[toggleDeleteModal, handleDispatchUpdate]
);
useEffect(() => {
handleIsLoading(isDeleting || isLoadingCases.indexOf('caseUpdate') > -1);
}, [handleIsLoading, isDeleting, isLoadingCases]);
useEffect(() => {
if (isDeleted) {
if (refreshCases != null) refreshCases();
dispatchResetIsDeleted();
}
}, [isDeleted, dispatchResetIsDeleted, refreshCases]);
return [
{
name: i18n.NAME,
render: (theCase: Case | SubCase) => {
if (theCase.id != null && theCase.title != null) {
const caseDetailsLinkComponent = !isModal ? (
<CaseDetailsLink
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
title={theCase.title}
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
>
{theCase.title}
</CaseDetailsLink>
) : (
<span>{theCase.title}</span>
);
const caseDetailsLinkComponent =
caseDetailsNavigation != null ? (
<CaseDetailsLink
caseDetailsNavigation={caseDetailsNavigation}
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
title={theCase.title}
>
{theCase.title}
</CaseDetailsLink>
) : (
<span>{theCase.title}</span>
);
return theCase.status !== CaseStatuses.closed ? (
caseDetailsLinkComponent
) : (
@ -218,15 +291,26 @@ export const getCasesColumns = (
));
},
},
{
name: i18n.ACTIONS,
actions,
},
...(showActions
? [
{
name: (
<>
{i18n.ACTIONS}
<ConfirmDeleteCaseModal
caseTitle={deleteThisCase.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [deleteThisCase])}
/>
</>
),
actions,
},
]
: []),
];
if (isModal) {
columns.pop(); // remove actions if in modal
}
return columns;
};
interface Props {

View file

@ -0,0 +1,58 @@
/*
* 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, { FunctionComponent, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { CaseStatuses } from '../../../common';
import { Stats } from '../status';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
interface CountProps {
refresh?: number;
}
export const Count: FunctionComponent<CountProps> = ({ refresh }) => {
const {
countOpenCases,
countInProgressCases,
countClosedCases,
isLoading: isCasesStatusLoading,
fetchCasesStatus,
} = useGetCasesStatus();
useEffect(() => {
if (refresh != null) {
fetchCasesStatus();
}
}, [fetchCasesStatus, refresh]);
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<Stats
dataTestSubj="openStatsHeader"
caseCount={countOpenCases}
caseStatus={CaseStatuses.open}
isLoading={isCasesStatusLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Stats
dataTestSubj="inProgressStatsHeader"
caseCount={countInProgressCases}
caseStatus={CaseStatuses['in-progress']}
isLoading={isCasesStatusLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Stats
dataTestSubj="closedStatsHeader"
caseCount={countClosedCases}
caseStatus={CaseStatuses.closed}
isLoading={isCasesStatusLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -10,11 +10,11 @@ import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui';
import styled from 'styled-components';
import { Case, SubCase } from '../../containers/types';
import { CasesColumns } from './columns';
import { AssociationType } from '../../../../../cases/common/api';
import { AssociationType } from '../../../common';
type ExpandedRowMap = Record<string, Element> | {};
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
const EuiBasicTable: any = _EuiBasicTable;
const BasicTable = styled(EuiBasicTable)`
thead {
display: none;
@ -34,12 +34,10 @@ BasicTable.displayName = 'BasicTable';
export const getExpandedRowMap = ({
data,
columns,
isModal,
onSubCaseClick,
}: {
data: Case[] | null;
columns: CasesColumns[];
isModal: boolean;
onSubCaseClick?: (theSubCase: SubCase) => void;
}): ExpandedRowMap => {
if (data == null) {
@ -48,7 +46,7 @@ export const getExpandedRowMap = ({
const rowProps = (theSubCase: SubCase) => {
return {
...(isModal && onSubCaseClick ? { onClick: () => onSubCaseClick(theSubCase) } : {}),
...(onSubCaseClick ? { onClick: () => onSubCaseClick(theSubCase) } : {}),
className: 'subCase',
};
};

View file

@ -0,0 +1,66 @@
/*
* 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, { FunctionComponent } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled, { css } from 'styled-components';
import { CaseHeaderPage } from '../case_header_page';
import * as i18n from './translations';
import { Count } from './count';
import { CasesNavigation } from '../links';
import { ErrorMessage } from '../callout/types';
import { NavButtons } from './nav_buttons';
interface OwnProps {
actionsErrors: ErrorMessage[];
configureCasesNavigation: CasesNavigation;
createCaseNavigation: CasesNavigation;
refresh: number;
userCanCrud: boolean;
}
type Props = OwnProps;
const FlexItemDivider = styled(EuiFlexItem)`
${({ theme }) => css`
.euiFlexGroup--gutterMedium > &.euiFlexItem {
border-right: ${theme.eui.euiBorderThin};
padding-right: ${theme.eui.euiSize};
margin-right: ${theme.eui.euiSize};
}
`}
`;
export const CasesTableHeader: FunctionComponent<Props> = ({
actionsErrors,
configureCasesNavigation,
createCaseNavigation,
refresh,
userCanCrud,
}) => (
<CaseHeaderPage title={i18n.PAGE_TITLE}>
<EuiFlexGroup
alignItems="center"
gutterSize="m"
responsive={false}
wrap={true}
data-test-subj="all-cases-header"
>
<FlexItemDivider grow={false}>
<Count refresh={refresh} />
</FlexItemDivider>
<EuiFlexItem grow={false}>
<NavButtons
actionsErrors={actionsErrors}
configureCasesNavigation={configureCasesNavigation}
createCaseNavigation={createCaseNavigation}
userCanCrud={userCanCrud}
/>
</EuiFlexItem>
</EuiFlexGroup>
</CaseHeaderPage>
);

View file

@ -6,7 +6,7 @@
*/
import { filter } from 'lodash/fp';
import { AssociationType, CaseStatuses, CaseType } from '../../../../../cases/common/api';
import { AssociationType, CaseStatuses, CaseType } from '../../../common';
import { Case, SubCase } from '../../containers/types';
import { statuses } from '../status';

View file

@ -9,41 +9,52 @@ import React from 'react';
import { mount } from 'enzyme';
import moment from 'moment-timezone';
import { waitFor } from '@testing-library/react';
import '../../../common/mock/match_media';
import { TestProviders } from '../../../common/mock';
import '../../common/mock/match_media';
import { TestProviders } from '../../common/mock';
import { casesStatus, useGetCasesMockState, collectionCase } from '../../containers/mock';
import * as i18n from './translations';
import { CaseStatuses, CaseType } from '../../../../../cases/common/api';
import { useKibana } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { CaseStatuses, CaseType, 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 { getCasesColumns } from './columns';
import { AllCases } from '.';
import { StatusAll } from '../status';
import { AllCasesGeneric as AllCases } from './all_cases_generic';
import { AllCasesProps } from '.';
import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns';
import { renderHook } from '@testing-library/react-hooks';
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');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
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;
jest.mock('../../../common/components/link_to');
jest.mock('../../common/lib/kibana');
jest.mock('../../../common/lib/kibana');
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,
};
describe('AllCases', () => {
const dispatchResetIsDeleted = jest.fn();
const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
@ -97,12 +108,20 @@ describe('AllCases', () => {
isError: false,
};
let navigateToApp: jest.Mock;
const defaultColumnArgs = {
caseDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
dispatchUpdateCaseProperty: jest.fn,
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
isLoadingCases: [],
showActions: true,
};
beforeEach(() => {
jest.clearAllMocks();
navigateToApp = jest.fn();
useKibanaMock().services.application.navigateToApp = navigateToApp;
useUpdateCasesMock.mockReturnValue(defaultUpdateCases);
useGetCasesMock.mockReturnValue(defaultGetCases);
useDeleteCasesMock.mockReturnValue(defaultDeleteCases);
@ -119,13 +138,13 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().prop('href')).toEqual(
`/${useGetCasesMockState.data.cases[0].id}`
`testHref`
);
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().text()).toEqual(
useGetCasesMockState.data.cases[0].title
@ -157,7 +176,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -193,7 +212,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -234,20 +253,22 @@ describe('AllCases', () => {
});
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
const checkIt = (columnName: string, key: number) => {
const column = wrapper.find('[data-test-subj="cases-table"] tbody .euiTableRowCell').at(key);
if (columnName === i18n.ACTIONS) {
return;
}
expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName);
expect(column.find('span').text()).toEqual(emptyTag);
};
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns(defaultColumnArgs)
);
await waitFor(() => {
getCasesColumns([], CaseStatuses.open, false).map(
(i, key) => i.name != null && checkIt(`${i.name}`, key)
result.current.map(
(i, key) => i.name != null && !i.hasOwnProperty('actions') && checkIt(`${i.name}`, key)
);
});
});
@ -259,7 +280,7 @@ describe('AllCases', () => {
});
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
@ -301,7 +322,7 @@ describe('AllCases', () => {
});
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -326,19 +347,24 @@ describe('AllCases', () => {
});
});
it('should not render case link or actions on modal=true', async () => {
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 userCanCrud={true} isModal={true} />
<AllCases {...rest} />
</TestProviders>
);
const { result } = renderHook<GetCasesColumn, CasesColumns[]>(() =>
useCasesColumns({
dispatchUpdateCaseProperty: jest.fn,
isLoadingCases: [],
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
showActions: false,
})
);
await waitFor(() => {
const checkIt = (columnName: string) => {
expect(columnName).not.toEqual(i18n.ACTIONS);
};
getCasesColumns([], CaseStatuses.open, true).map(
(i, key) => i.name != null && checkIt(`${i.name}`)
);
result.current.map((i) => i.name != null && !i.hasOwnProperty('actions'));
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy();
});
});
@ -346,7 +372,7 @@ describe('AllCases', () => {
it('should tableHeaderSortButton AllCases', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="tableHeaderSortButton"]').first().simulate('click');
@ -363,7 +389,7 @@ describe('AllCases', () => {
it('closes case when row action icon clicked', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
@ -371,13 +397,14 @@ describe('AllCases', () => {
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.closed,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.closed,
version: firstCase.version,
})
);
});
});
@ -398,7 +425,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -407,20 +434,21 @@ describe('AllCases', () => {
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.open,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.open,
version: firstCase.version,
})
);
});
});
it('put case in progress when row action icon clicked', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -429,13 +457,14 @@ describe('AllCases', () => {
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses['in-progress'],
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses['in-progress'],
version: firstCase.version,
})
);
});
});
@ -458,7 +487,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -495,7 +524,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -513,7 +542,7 @@ describe('AllCases', () => {
});
});
it('Renders correct bulk actoins for case collection when filter status is set to all - enable only bulk delete if any collection is selected', async () => {
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 },
@ -538,7 +567,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
@ -565,7 +594,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
@ -588,7 +617,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
@ -607,7 +636,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
@ -628,7 +657,7 @@ describe('AllCases', () => {
mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
@ -646,7 +675,7 @@ describe('AllCases', () => {
mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
await waitFor(() => {
@ -656,10 +685,11 @@ describe('AllCases', () => {
});
});
it('should not render header when modal=true', async () => {
it('should not render header when configureCasesNavigation are not present', async () => {
const { configureCasesNavigation, ...restProps } = defaultAllCasesProps;
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={true} />
<AllCases {...restProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
@ -667,23 +697,24 @@ describe('AllCases', () => {
});
});
it('should not render table utility bar when modal=true', async () => {
it('should not render table utility bar when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={true} />
<AllCases {...defaultAllCasesProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-table-utility-bar-actions"]').exists()).toBe(
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 modal=true', async () => {
it('case table should not be selectable when isSelectorView=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={true} />
<AllCases {...defaultAllCasesProps} isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
@ -693,7 +724,7 @@ describe('AllCases', () => {
});
});
it('should call onRowClick with no cases and modal=true', async () => {
it('should call onRowClick with no cases and isSelectorView=true', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
@ -705,7 +736,12 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={true} onRowClick={onRowClick} />
<AllCases
{...defaultAllCasesProps}
userCanCrud={true}
isSelectorView={true}
onRowClick={onRowClick}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click');
@ -714,7 +750,8 @@ describe('AllCases', () => {
});
});
it('should call navigateToApp with no cases and modal=false', async () => {
it('should call createCaseNavigation.onClick with no cases and isSelectorView=false', async () => {
const createCaseNavigation = { href: '', onClick: jest.fn() };
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
@ -726,19 +763,28 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases
{...defaultAllCasesProps}
createCaseNavigation={createCaseNavigation}
isSelectorView={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click');
await waitFor(() => {
expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' });
expect(createCaseNavigation.onClick).toHaveBeenCalled();
});
});
it('should call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={true} onRowClick={onRowClick} />
<AllCases
{...defaultAllCasesProps}
userCanCrud={true}
isSelectorView={true}
onRowClick={onRowClick}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click');
@ -793,7 +839,7 @@ describe('AllCases', () => {
it('should NOT call onRowClick when clicking a case with modal=true', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click');
@ -805,7 +851,7 @@ describe('AllCases', () => {
it('should change the status to closed', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
@ -820,7 +866,7 @@ describe('AllCases', () => {
it('should change the status to in-progress', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
@ -835,7 +881,7 @@ describe('AllCases', () => {
it('should change the status to open', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
@ -850,7 +896,7 @@ describe('AllCases', () => {
it('should show the correct count on stats', async () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} isModal={false} />
<AllCases {...defaultAllCasesProps} isSelectorView={false} />
</TestProviders>
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
@ -882,7 +928,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
@ -908,7 +954,7 @@ describe('AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);

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 React from 'react';
import { CaseDetailsHrefSchema, CasesNavigation } from '../links';
import { AllCasesGeneric } from './all_cases_generic';
export interface AllCasesProps {
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;
userCanCrud: boolean;
}
export const AllCases: React.FC<AllCasesProps> = (props) => {
return <AllCasesGeneric {...props} />;
};
// eslint-disable-next-line import/no-default-export
export { AllCases as default };

View file

@ -0,0 +1,55 @@
/*
* 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, { FunctionComponent } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { ConfigureCaseButton } from '../configure_cases/button';
import * as i18n from './translations';
import { CasesNavigation, LinkButton } from '../links';
import { ErrorMessage } from '../callout/types';
interface OwnProps {
actionsErrors: ErrorMessage[];
configureCasesNavigation: CasesNavigation;
createCaseNavigation: CasesNavigation;
userCanCrud: boolean;
}
type Props = OwnProps;
export const NavButtons: FunctionComponent<Props> = ({
actionsErrors,
configureCasesNavigation,
createCaseNavigation,
userCanCrud,
}) => (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<ConfigureCaseButton
configureCasesNavigation={configureCasesNavigation}
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors) || !userCanCrud}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].description : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkButton
isDisabled={!userCanCrud}
fill
onClick={createCaseNavigation.onClick}
href={createCaseNavigation.href}
iconType="plusInCircle"
data-test-subj="createNewCaseBtn"
>
{i18n.CREATE_TITLE}
</LinkButton>
</EuiFlexItem>
</EuiFlexGroup>
);

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 from 'react';
import { mount } from 'enzyme';
import { AllCasesSelectorModal } from '.';
import { TestProviders } from '../../../common/mock';
import { AllCasesGeneric } from '../all_cases_generic';
jest.mock('../../../methods');
jest.mock('../all_cases_generic');
const onRowClick = jest.fn();
const createCaseNavigation = { href: '', onClick: jest.fn() };
const defaultProps = {
createCaseNavigation,
onRowClick,
userCanCrud: true,
};
const updateCase = jest.fn();
describe('AllCasesSelectorModal', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('renders', () => {
const wrapper = mount(
<TestProviders>
<AllCasesSelectorModal {...defaultProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy();
});
it('Closing modal calls onCloseCaseModal', () => {
const wrapper = mount(
<TestProviders>
<AllCasesSelectorModal {...defaultProps} />
</TestProviders>
);
wrapper.find('.euiModal__closeIcon').first().simulate('click');
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy();
});
it('pass the correct props to getAllCases method', () => {
const fullProps = {
...defaultProps,
alertData: {
rule: {
id: 'rule-id',
name: 'rule',
},
index: 'index-id',
alertId: 'alert-id',
},
disabledStatuses: [],
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.objectContaining({
alertData: fullProps.alertData,
createCaseNavigation,
disabledStatuses: fullProps.disabledStatuses,
isSelectorView: true,
userCanCrud: fullProps.userCanCrud,
updateCase,
})
);
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui';
import styled from 'styled-components';
import { Case, CaseStatuses, CommentRequestAlertType, SubCase } from '../../../../common';
import { CasesNavigation } from '../../links';
import * as i18n from '../../../common/translations';
import { AllCasesGeneric } from '../all_cases_generic';
export interface AllCasesSelectorModalProps {
alertData?: Omit<CommentRequestAlertType, 'type'>;
createCaseNavigation: CasesNavigation;
disabledStatuses?: CaseStatuses[];
onRowClick: (theCase?: Case | SubCase) => void;
updateCase?: (newCase: Case) => void;
userCanCrud: boolean;
}
const Modal = styled(EuiModal)`
${({ theme }) => `
width: ${theme.eui.euiBreakpoints.l};
max-width: ${theme.eui.euiBreakpoints.l};
`}
`;
export const AllCasesSelectorModal: React.FC<AllCasesSelectorModalProps> = ({
alertData,
createCaseNavigation,
disabledStatuses,
onRowClick,
updateCase,
userCanCrud,
}) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const closeModal = useCallback(() => setIsModalOpen(false), []);
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}
disabledStatuses={disabledStatuses}
isSelectorView={true}
onRowClick={onClick}
userCanCrud={userCanCrud}
updateCase={updateCase}
/>
</EuiModalBody>
</Modal>
) : null;
};
// eslint-disable-next-line import/no-default-export
export { AllCasesSelectorModal as default };

View file

@ -9,9 +9,8 @@ import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses, StatusAll } from '../../../common';
import { StatusFilter } from './status_filter';
import { StatusAll } from '../status';
const stats = {
[StatusAll]: 0,

View file

@ -7,7 +7,8 @@
import React, { memo } from 'react';
import { EuiSuperSelect, EuiSuperSelectOption, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Status, statuses, StatusAll, CaseStatusWithAllStatus } from '../status';
import { Status, statuses } from '../status';
import { CaseStatusWithAllStatus, StatusAll } from '../../../common';
interface Props {
stats: Record<CaseStatusWithAllStatus, number | null>;

View file

@ -0,0 +1,148 @@
/*
* 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, { FunctionComponent } from 'react';
import {
EuiEmptyPrompt,
EuiLoadingContent,
EuiTableSelectionType,
EuiBasicTable as _EuiBasicTable,
EuiBasicTableProps,
} from '@elastic/eui';
import classnames from 'classnames';
import styled from 'styled-components';
import { CasesTableUtilityBar } from './utility_bar';
import { CasesNavigation, LinkButton } from '../links';
import { AllCases, Case, FilterOptions } from '../../../common';
import * as i18n from './translations';
interface CasesTableProps {
columns: EuiBasicTableProps<Case>['columns']; // CasesColumns[];
createCaseNavigation: CasesNavigation;
data: AllCases;
filterOptions: FilterOptions;
goToCreateCase: (e: React.MouseEvent) => void;
handleIsLoading: (a: boolean) => void;
isCasesLoading: boolean;
isCommentUpdating: boolean;
isDataEmpty: boolean;
isSelectorView?: boolean;
itemIdToExpandedRowMap: EuiBasicTableProps<Case>['itemIdToExpandedRowMap'];
onChange: EuiBasicTableProps<Case>['onChange'];
pagination: EuiBasicTableProps<Case>['pagination'];
refreshCases: (a?: boolean) => void;
selectedCases: Case[];
selection: EuiTableSelectionType<Case>;
showActions: boolean;
sorting: EuiBasicTableProps<Case>['sorting'];
tableRowProps: EuiBasicTableProps<Case>['rowProps'];
userCanCrud: boolean;
}
const EuiBasicTable: any = _EuiBasicTable;
const BasicTable = styled(EuiBasicTable)`
${({ theme }) => `
.euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent {
padding: 8px 0 8px 32px;
}
&.isSelectorView .euiTableRow.isDisabled {
cursor: not-allowed;
background-color: ${theme.eui.euiTableHoverClickableColor};
}
&.isSelectorView .euiTableRow.euiTableRow-isExpandedRow .euiTableRowCell,
&.isSelectorView .euiTableRow.euiTableRow-isExpandedRow:hover {
background-color: transparent;
}
&.isSelectorView .euiTableRow.euiTableRow-isExpandedRow {
.subCase:hover {
background-color: ${theme.eui.euiTableHoverClickableColor};
}
}
`}
`;
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
`;
export const CasesTable: FunctionComponent<CasesTableProps> = ({
columns,
createCaseNavigation,
data,
filterOptions,
goToCreateCase,
handleIsLoading,
isCasesLoading,
isCommentUpdating,
isDataEmpty,
isSelectorView,
itemIdToExpandedRowMap,
onChange,
pagination,
refreshCases,
selectedCases,
selection,
showActions,
sorting,
tableRowProps,
userCanCrud,
}) =>
isCasesLoading && isDataEmpty ? (
<Div>
<EuiLoadingContent data-test-subj="initialLoadingPanelAllCases" lines={10} />
</Div>
) : (
<Div>
<CasesTableUtilityBar
data={data}
enableBulkActions={showActions}
filterOptions={filterOptions}
handleIsLoading={handleIsLoading}
selectedCases={selectedCases}
refreshCases={refreshCases}
/>
<BasicTable
columns={columns}
data-test-subj="cases-table"
isSelectable={showActions}
itemId="id"
items={data.cases}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
loading={isCommentUpdating}
noItemsMessage={
<EuiEmptyPrompt
title={<h3>{i18n.NO_CASES}</h3>}
titleSize="xs"
body={i18n.NO_CASES_BODY}
actions={
<LinkButton
isDisabled={!userCanCrud}
fill
size="s"
onClick={goToCreateCase}
href={createCaseNavigation.href}
iconType="plusInCircle"
data-test-subj="cases-table-add-case"
>
{i18n.ADD_NEW_CASE}
</LinkButton>
}
/>
}
onChange={onChange}
pagination={pagination}
rowProps={tableRowProps}
selection={showActions ? selection : undefined}
sorting={sorting}
className={classnames({ isSelectorView })}
/>
</Div>
);

View file

@ -8,8 +8,8 @@
import React from 'react';
import { mount } from 'enzyme';
import { CaseStatuses } from '../../../../../cases/common/api';
import { TestProviders } from '../../../common/mock';
import { CaseStatuses } from '../../../common';
import { TestProviders } from '../../common/mock';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases';

View file

@ -10,12 +10,11 @@ import { isEqual } from 'lodash/fp';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup } from '@elastic/eui';
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses, CaseStatusWithAllStatus, StatusAll } from '../../../common';
import { FilterOptions } from '../../containers/types';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { FilterPopover } from '../filter_popover';
import { CaseStatusWithAllStatus, StatusAll } from '../status';
import { StatusFilter } from './status_filter';
import * as i18n from './translations';
@ -78,22 +77,6 @@ const CasesTableFiltersComponent = ({
}
}, [refetch, setFilterRefetch]);
useEffect(() => {
if (selectedReporters.length) {
const newReporters = selectedReporters.filter((r) => reporters.includes(r));
handleSelectedReporters(newReporters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reporters]);
useEffect(() => {
if (selectedTags.length) {
const newTags = selectedTags.filter((t) => tags.includes(t));
handleSelectedTags(newTags);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const handleSelectedReporters = useCallback(
(newReporters) => {
if (!isEqual(newReporters, selectedReporters)) {
@ -104,10 +87,16 @@ const CasesTableFiltersComponent = ({
onFilterChanged({ reporters: reportersObj });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedReporters, respReporters]
[selectedReporters, respReporters, onFilterChanged]
);
useEffect(() => {
if (selectedReporters.length) {
const newReporters = selectedReporters.filter((r) => reporters.includes(r));
handleSelectedReporters(newReporters);
}
}, [handleSelectedReporters, reporters, selectedReporters]);
const handleSelectedTags = useCallback(
(newTags) => {
if (!isEqual(newTags, selectedTags)) {
@ -115,10 +104,16 @@ const CasesTableFiltersComponent = ({
onFilterChanged({ tags: newTags });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedTags]
[onFilterChanged, selectedTags]
);
useEffect(() => {
if (selectedTags.length) {
const newTags = selectedTags.filter((t) => tags.includes(t));
handleSelectedTags(newTags);
}
}, [handleSelectedTags, selectedTags, tags]);
const handleOnSearch = useCallback(
(newSearch) => {
const trimSearch = newSearch.trim();
@ -127,8 +122,7 @@ const CasesTableFiltersComponent = ({
onFilterChanged({ search: trimSearch });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[search]
[onFilterChanged, search]
);
const onStatusChanged = useCallback(

View file

@ -0,0 +1,91 @@
/*
* 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 * from '../../common/translations';
export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', {
defaultMessage: 'No Cases',
});
export const NO_CASES_BODY = i18n.translate('xpack.cases.caseTable.noCases.body', {
defaultMessage:
'There are no cases to display. Please create a new case or change your filter settings above.',
});
export const ADD_NEW_CASE = i18n.translate('xpack.cases.caseTable.addNewCase', {
defaultMessage: 'Add New Case',
});
export const SHOWING_SELECTED_CASES = (totalRules: number) =>
i18n.translate('xpack.cases.caseTable.selectedCasesTitle', {
values: { totalRules },
defaultMessage: 'Selected {totalRules} {totalRules, plural, =1 {case} other {cases}}',
});
export const SHOWING_CASES = (totalRules: number) =>
i18n.translate('xpack.cases.caseTable.showingCasesTitle', {
values: { totalRules },
defaultMessage: 'Showing {totalRules} {totalRules, plural, =1 {case} other {cases}}',
});
export const UNIT = (totalCount: number) =>
i18n.translate('xpack.cases.caseTable.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`,
});
export const SEARCH_CASES = i18n.translate('xpack.cases.caseTable.searchAriaLabel', {
defaultMessage: 'Search cases',
});
export const BULK_ACTIONS = i18n.translate('xpack.cases.caseTable.bulkActions', {
defaultMessage: 'Bulk actions',
});
export const EXTERNAL_INCIDENT = i18n.translate('xpack.cases.caseTable.snIncident', {
defaultMessage: 'External Incident',
});
export const INCIDENT_MANAGEMENT_SYSTEM = i18n.translate('xpack.cases.caseTable.incidentSystem', {
defaultMessage: 'Incident Management System',
});
export const SEARCH_PLACEHOLDER = i18n.translate('xpack.cases.caseTable.searchPlaceholder', {
defaultMessage: 'e.g. case name',
});
export const CLOSED = i18n.translate('xpack.cases.caseTable.closed', {
defaultMessage: 'Closed',
});
export const DELETE = i18n.translate('xpack.cases.caseTable.delete', {
defaultMessage: 'Delete',
});
export const REQUIRES_UPDATE = i18n.translate('xpack.cases.caseTable.requiresUpdate', {
defaultMessage: ' requires update',
});
export const UP_TO_DATE = i18n.translate('xpack.cases.caseTable.upToDate', {
defaultMessage: ' is up to date',
});
export const NOT_PUSHED = i18n.translate('xpack.cases.caseTable.notPushed', {
defaultMessage: 'Not pushed',
});
export const REFRESH = i18n.translate('xpack.cases.caseTable.refreshTitle', {
defaultMessage: 'Refresh',
});
export const SERVICENOW_LINK_ARIA = i18n.translate('xpack.cases.caseTable.serviceNowLinkAria', {
defaultMessage: 'click to view the incident on servicenow',
});
export const STATUS = i18n.translate('xpack.cases.caseTable.status', {
defaultMessage: 'Status',
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
/* eslint-disable @typescript-eslint/naming-convention */
export const sort_order = t.keyof({ asc: null, desc: null });
export type SortOrder = t.TypeOf<typeof sort_order>;
export interface EuiBasicTableSortTypes {
field: string;
direction: SortOrder;
}
export interface EuiBasicTableOnChange {
page: {
index: number;
size: number;
};
sort?: EuiBasicTableSortTypes;
}

View file

@ -0,0 +1,173 @@
/*
* 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, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { EuiContextMenuPanel } from '@elastic/eui';
import {
UtilityBar,
UtilityBarAction,
UtilityBarGroup,
UtilityBarSection,
UtilityBarText,
} from '../utility_bar';
import * as i18n from './translations';
import { AllCases, Case, DeleteCase, FilterOptions } from '../../../common';
import { getBulkItems } from '../bulk_actions';
import { isSelectedCasesIncludeCollections } from './helpers';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
interface OwnProps {
data: AllCases;
enableBulkActions: boolean;
filterOptions: FilterOptions;
handleIsLoading: (a: boolean) => void;
refreshCases?: (a?: boolean) => void;
selectedCases: Case[];
}
type Props = OwnProps;
export const CasesTableUtilityBar: FunctionComponent<Props> = ({
data,
enableBulkActions = false,
filterOptions,
handleIsLoading,
refreshCases,
selectedCases,
}) => {
const [deleteBulk, setDeleteBulk] = useState<DeleteCase[]>([]);
const [deleteThisCase, setDeleteThisCase] = useState<DeleteCase>({
title: '',
id: '',
type: null,
});
// Delete case
const {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isLoading: isDeleting,
isDeleted,
isDisplayConfirmDeleteModal,
} = useDeleteCases();
// Update case
const {
dispatchResetIsUpdated,
isLoading: isUpdating,
isUpdated,
updateBulkStatus,
} = useUpdateCases();
useEffect(() => {
handleIsLoading(isDeleting);
}, [handleIsLoading, isDeleting]);
useEffect(() => {
handleIsLoading(isUpdating);
}, [handleIsLoading, isUpdating]);
useEffect(() => {
if (isDeleted) {
if (refreshCases != null) refreshCases();
dispatchResetIsDeleted();
}
if (isUpdated) {
if (refreshCases != null) refreshCases();
dispatchResetIsUpdated();
}
}, [isDeleted, isUpdated, refreshCases, dispatchResetIsDeleted, dispatchResetIsUpdated]);
const toggleBulkDeleteModal = useCallback(
(cases: Case[]) => {
handleToggleModal();
if (cases.length === 1) {
const singleCase = cases[0];
if (singleCase) {
return setDeleteThisCase({
id: singleCase.id,
title: singleCase.title,
type: singleCase.type,
});
}
}
const convertToDeleteCases: DeleteCase[] = cases.map(({ id, title, type }) => ({
id,
title,
type,
}));
setDeleteBulk(convertToDeleteCases);
},
[setDeleteBulk, handleToggleModal]
);
const handleUpdateCaseStatus = useCallback(
(status: string) => {
updateBulkStatus(selectedCases, status);
},
[selectedCases, updateBulkStatus]
);
const getBulkItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel
data-test-subj="cases-bulk-actions"
items={getBulkItems({
caseStatus: filterOptions.status,
closePopover,
deleteCasesAction: toggleBulkDeleteModal,
selectedCases,
updateCaseStatus: handleUpdateCaseStatus,
includeCollections: isSelectedCasesIncludeCollections(selectedCases),
})}
/>
),
[selectedCases, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus]
);
return (
<UtilityBar border>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText data-test-subj="case-table-case-count">
{i18n.SHOWING_CASES(data.total ?? 0)}
</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup data-test-subj="case-table-utility-bar-actions">
{enableBulkActions && (
<>
<UtilityBarText data-test-subj="case-table-selected-case-count">
{i18n.SHOWING_SELECTED_CASES(selectedCases.length)}
</UtilityBarText>
<UtilityBarAction
data-test-subj="case-table-bulk-actions"
iconSide="right"
iconType="arrowDown"
popoverContent={getBulkItemsPopoverContent}
>
{i18n.BULK_ACTIONS}
</UtilityBarAction>
</>
)}
<UtilityBarAction iconSide="left" iconType="refresh" onClick={refreshCases}>
{i18n.REFRESH}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
<ConfirmDeleteCaseModal
caseTitle={deleteThisCase.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={deleteBulk.length > 0}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(
null,
deleteBulk.length > 0 ? deleteBulk : [deleteThisCase]
)}
/>
</UtilityBar>
);
};

View file

@ -8,8 +8,8 @@
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { CaseStatuses } from '../../../../../cases/common/api';
import { statuses, CaseStatusWithAllStatus } from '../status';
import { CaseStatuses, CaseStatusWithAllStatus } from '../../../common';
import { statuses } from '../status';
import * as i18n from './translations';
import { Case } from '../../containers/types';

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
export const BULK_ACTION_DELETE_SELECTED = i18n.translate(
'xpack.securitySolution.cases.caseTable.bulkActions.deleteSelectedTitle',
'xpack.cases.caseTable.bulkActions.deleteSelectedTitle',
{
defaultMessage: 'Delete selected',
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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 { CallOut, CallOutProps } from './callout';
describe('Callout', () => {
const defaultProps: CallOutProps = {
id: 'md5-hex',
type: 'primary',
title: 'a tittle',
messages: [
{
id: 'generic-error',
title: 'message-one',
description: <p>{'error'}</p>,
},
],
showCallOut: true,
handleDismissCallout: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('It renders the callout', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
});
it('hides the callout', () => {
const wrapper = mount(<CallOut {...defaultProps} showCallOut={false} />);
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeFalsy();
});
it('does not shows any messages when the list is empty', () => {
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy();
});
it('transform the button color correctly - primary', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--primary')).toBeTruthy();
});
it('transform the button color correctly - success', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'success'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--secondary')).toBeTruthy();
});
it('transform the button color correctly - warning', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'warning'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--warning')).toBeTruthy();
});
it('transform the button color correctly - danger', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'danger'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--danger')).toBeTruthy();
});
it('dismiss the callout correctly', () => {
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
wrapper.update();
expect(defaultProps.handleDismissCallout).toHaveBeenCalledWith('md5-hex', 'primary');
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { memo, useCallback } from 'react';
import { ErrorMessage } from './types';
import * as i18n from './translations';
export interface CallOutProps {
id: string;
type: NonNullable<ErrorMessage['errorType']>;
title: string;
messages: ErrorMessage[];
showCallOut: boolean;
handleDismissCallout: (id: string, type: NonNullable<ErrorMessage['errorType']>) => void;
}
const CallOutComponent = ({
id,
type,
title,
messages,
showCallOut,
handleDismissCallout,
}: CallOutProps) => {
const handleCallOut = useCallback(() => handleDismissCallout(id, type), [
handleDismissCallout,
id,
type,
]);
return showCallOut ? (
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
{!isEmpty(messages) && (
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
)}
<EuiButton
data-test-subj={`callout-dismiss-${id}`}
color={type === 'success' ? 'secondary' : type}
onClick={handleCallOut}
>
{i18n.DISMISS_CALLOUT}
</EuiButton>
</EuiCallOut>
) : null;
};
export const CallOut = memo(CallOutComponent);

View file

@ -0,0 +1,29 @@
/*
* 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 md5 from 'md5';
import { createCalloutId } from './helpers';
describe('createCalloutId', () => {
it('creates id correctly with one id', () => {
const digest = md5('one');
const id = createCalloutId(['one']);
expect(id).toBe(digest);
});
it('creates id correctly with multiples ids', () => {
const digest = md5('one|two|three');
const id = createCalloutId(['one', 'two', 'three']);
expect(id).toBe(digest);
});
it('creates id correctly with multiples ids and delimiter', () => {
const digest = md5('one,two,three');
const id = createCalloutId(['one', 'two', 'three'], ',');
expect(id).toBe(digest);
});
});

View file

@ -0,0 +1,22 @@
/*
* 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 md5 from 'md5';
import * as i18n from './translations';
import { ErrorMessage } from './types';
export const savedObjectReadOnlyErrorMessage: ErrorMessage = {
id: 'read-only-privileges-error',
title: i18n.READ_ONLY_SAVED_OBJECT_TITLE,
description: <>{i18n.READ_ONLY_SAVED_OBJECT_MSG}</>,
errorType: 'warning',
};
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));

View file

@ -0,0 +1,217 @@
/*
* 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 { useMessagesStorage } from '../../containers/use_messages_storage';
import { TestProviders } from '../../common/mock';
import { createCalloutId } from './helpers';
import { CaseCallOut, CaseCallOutProps } from '.';
jest.mock('../../containers/use_messages_storage');
const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock;
const securityLocalStorageMock = {
getMessages: jest.fn(() => []),
addMessage: jest.fn(),
};
describe('CaseCallOut ', () => {
beforeEach(() => {
jest.clearAllMocks();
useSecurityLocalStorageMock.mockImplementation(() => securityLocalStorageMock);
});
it('renders a callout correctly', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
{ id: 'message-two', title: 'title', description: <p>{'for real'}</p> },
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one', 'message-two']);
expect(wrapper.find(`[data-test-subj="callout-messages-${id}"]`).last().exists()).toBeTruthy();
});
it('groups the messages correctly', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{
id: 'message-one',
title: 'title one',
description: <p>{'we have two messages'}</p>,
errorType: 'danger',
},
{ id: 'message-two', title: 'title two', description: <p>{'for real'}</p> },
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const idDanger = createCalloutId(['message-one']);
const idPrimary = createCalloutId(['message-two']);
expect(
wrapper.find(`[data-test-subj="case-callout-${idPrimary}"]`).last().exists()
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="case-callout-${idDanger}"]`).last().exists()
).toBeTruthy();
});
it('dismisses the callout correctly', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one']);
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeTruthy();
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).exists()).toBeFalsy();
});
it('persist the callout of type primary when dismissed', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one']);
expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith('case');
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith('case', id);
});
it('do not show the callout if is in the localStorage', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
],
};
const id = createCalloutId(['message-one']);
useSecurityLocalStorageMock.mockImplementation(() => ({
...securityLocalStorageMock,
getMessages: jest.fn(() => [id]),
}));
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeFalsy();
});
it('do not persist a callout of type danger', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{
id: 'message-one',
title: 'title one',
description: <p>{'we have two messages'}</p>,
errorType: 'danger',
},
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one']);
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
wrapper.update();
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
});
it('do not persist a callout of type warning', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{
id: 'message-one',
title: 'title one',
description: <p>{'we have two messages'}</p>,
errorType: 'warning',
},
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one']);
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
wrapper.update();
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
});
it('do not persist a callout of type success', () => {
const props: CaseCallOutProps = {
title: 'hey title',
messages: [
{
id: 'message-one',
title: 'title one',
description: <p>{'we have two messages'}</p>,
errorType: 'success',
},
],
};
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
const id = createCalloutId(['message-one']);
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
wrapper.update();
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,103 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import React, { memo, useCallback, useState, useMemo } from 'react';
import { useMessagesStorage } from '../../containers/use_messages_storage';
import { CallOut } from './callout';
import { ErrorMessage } from './types';
import { createCalloutId } from './helpers';
export * from './helpers';
export interface CaseCallOutProps {
title: string;
messages?: ErrorMessage[];
}
type GroupByTypeMessages = {
[key in NonNullable<ErrorMessage['errorType']>]: {
messagesId: string[];
messages: ErrorMessage[];
};
};
interface CalloutVisibility {
[index: string]: boolean;
}
const CaseCallOutComponent = ({ title, messages = [] }: CaseCallOutProps) => {
const { getMessages, addMessage } = useMessagesStorage();
const caseMessages = useMemo(() => getMessages('case'), [getMessages]);
const dismissedCallouts = useMemo(
() =>
caseMessages.reduce<CalloutVisibility>(
(acc, id) => ({
...acc,
[id]: false,
}),
{}
),
[caseMessages]
);
const [calloutVisibility, setCalloutVisibility] = useState(dismissedCallouts);
const handleCallOut = useCallback(
(id, type) => {
setCalloutVisibility((prevState) => ({ ...prevState, [id]: false }));
if (type === 'primary') {
addMessage('case', id);
}
},
[setCalloutVisibility, addMessage]
);
const groupedByTypeErrorMessages = useMemo(
() =>
messages.reduce<GroupByTypeMessages>(
(acc: GroupByTypeMessages, currentMessage: ErrorMessage) => {
const type = currentMessage.errorType == null ? 'primary' : currentMessage.errorType;
return {
...acc,
[type]: {
messagesId: [...(acc[type]?.messagesId ?? []), currentMessage.id],
messages: [...(acc[type]?.messages ?? []), currentMessage],
},
};
},
{} as GroupByTypeMessages
),
[messages]
);
return (
<>
{(Object.keys(groupedByTypeErrorMessages) as Array<keyof ErrorMessage['errorType']>).map(
(type: NonNullable<ErrorMessage['errorType']>) => {
const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId);
return (
<React.Fragment key={id}>
<CallOut
id={id}
type={type}
title={title}
messages={groupedByTypeErrorMessages[type].messages}
showCallOut={calloutVisibility[id] ?? true}
handleDismissCallout={handleCallOut}
/>
<EuiSpacer />
</React.Fragment>
);
}
)}
</>
);
};
export const CaseCallOut = memo(CaseCallOutComponent);

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 { i18n } from '@kbn/i18n';
export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate('xpack.cases.readOnlySavedObjectTitle', {
defaultMessage: 'You cannot open new or update existing cases',
});
export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate(
'xpack.cases.readOnlySavedObjectDescription',
{
defaultMessage:
'You only have permissions to view cases. If you need to open and update cases, contact your Kibana administrator.',
}
);
export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', {
defaultMessage: 'Dismiss',
});

View file

@ -0,0 +1,13 @@
/*
* 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 interface ErrorMessage {
id: string;
title: string;
description: JSX.Element;
errorType?: 'primary' | 'success' | 'warning' | 'danger';
}

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { basicCase, basicPush } from '../../containers/mock';
import { Actions } from './actions';
import * as i18n from '../case_view/translations';

View file

@ -35,21 +35,6 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
isDisplayConfirmDeleteModal,
} = useDeleteCases();
const confirmDeleteModal = useMemo(
() => (
<ConfirmDeleteCaseModal
caseTitle={caseData.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [
{ id: caseData.id, title: caseData.title, type: caseData.type },
])}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[isDisplayConfirmDeleteModal, caseData]
);
const propertyActions = useMemo(
() => [
{
@ -78,7 +63,15 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
return (
<>
<PropertyActions propertyActions={propertyActions} />
{confirmDeleteModal}
<ConfirmDeleteCaseModal
caseTitle={caseData.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [
{ id: caseData.id, title: caseData.title, type: caseData.type },
])}
/>
</>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses } from '../../../common';
import { basicCase } from '../../containers/mock';
import { getStatusDate, getStatusTitle } from './helpers';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses } from '../../../common';
import { Case } from '../../containers/types';
import { statuses } from '../status';

View file

@ -10,7 +10,7 @@ import { mount } from 'enzyme';
import { basicCase } from '../../containers/mock';
import { CaseActionBar } from '.';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
describe('CaseActionBar', () => {
const onRefresh = jest.fn();

View file

@ -16,9 +16,9 @@ import {
EuiFlexItem,
EuiIconTip,
} from '@elastic/eui';
import { CaseStatuses, CaseType } from '../../../../../cases/common/api';
import { CaseStatuses, CaseType } from '../../../common';
import * as i18n from '../case_view/translations';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { FormattedRelativePreferenceDate } from '../formatted_date';
import { Actions } from './actions';
import { Case } from '../../containers/types';
import { CaseService } from '../../containers/use_get_case_user_actions';

View file

@ -8,7 +8,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { CaseStatuses } from '../../../../../cases/common/api';
import { CaseStatuses } from '../../../common';
import { StatusContextMenu } from './status_context_menu';
describe('SyncAlertsSwitch', () => {

View file

@ -8,7 +8,7 @@
import React, { memo, useCallback, useMemo, useState } from 'react';
import { memoize } from 'lodash/fp';
import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { caseStatuses, CaseStatuses } from '../../../../../cases/common/api';
import { caseStatuses, CaseStatuses } from '../../../common';
import { Status } from '../status';
interface Props {

View file

@ -0,0 +1,14 @@
/*
* 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 { HeaderPage, HeaderPageProps } from '../header_page';
const CaseHeaderPageComponent: React.FC<HeaderPageProps> = (props) => <HeaderPage {...props} />;
export const CaseHeaderPage = React.memo(CaseHeaderPageComponent);

View file

@ -8,7 +8,7 @@
import React, { memo, useCallback, useState } from 'react';
import { EuiSwitch } from '@elastic/eui';
import * as i18n from '../../translations';
import * as i18n from '../../common/translations';
interface Props {
disabled: boolean;

View file

@ -0,0 +1,58 @@
/*
* 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 { AssociationType, CommentType } from '../../../common';
import { Comment } from '../../containers/types';
import { getManualAlertIdsWithNoRuleId } from './helpers';
const comments: Comment[] = [
{
associationType: AssociationType.case,
type: CommentType.alert,
alertId: 'alert-id-1',
index: 'alert-index-1',
id: 'comment-id',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: { username: 'elastic' },
rule: {
id: null,
name: null,
},
pushedAt: null,
pushedBy: null,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFc',
},
{
associationType: AssociationType.case,
type: CommentType.alert,
alertId: 'alert-id-2',
index: 'alert-index-2',
id: 'comment-id',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: { username: 'elastic' },
pushedAt: null,
pushedBy: null,
rule: {
id: 'rule-id-2',
name: 'rule-name-2',
},
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFc',
},
];
describe('Case view helpers', () => {
describe('getAlertIdsFromComments', () => {
it('it returns the alert id from the comments where rule is not defined', () => {
expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']);
});
});
});

View file

@ -0,0 +1,22 @@
/*
* 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 { isEmpty } from 'lodash';
import { CommentType } from '../../../common';
import { Comment } from '../../containers/types';
export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => {
const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => {
if (comment.type === CommentType.alert && isEmpty(comment.rule.id)) {
const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId];
ids.forEach((id) => alertIds.add(id));
return alertIds;
}
return alertIds;
}, new Set<string>());
return [...dedupeAlerts];
};

View file

@ -8,9 +8,9 @@
import React from 'react';
import { mount } from 'enzyme';
import '../../../common/mock/match_media';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { CaseComponent, CaseProps, CaseView } from '.';
import '../../common/mock/match_media';
import { Router, mockHistory } from '../__mock__/router';
import { CaseComponent, CaseComponentProps, CaseView } from '.';
import {
basicCase,
basicCaseClosed,
@ -18,7 +18,7 @@ import {
alertComment,
getAlertUserAction,
} from '../../containers/mock';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
@ -27,54 +27,19 @@ import { waitFor } from '@testing-library/react';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ConnectorTypes } from '../../../../../cases/common/api/connectors';
import { CaseType } from '../../../../../cases/common/api';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
import { CaseType, ConnectorTypes } from '../../../common';
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../../../detections/containers/detection_engine/alerts/use_query');
jest.mock('../user_action_tree/user_action_timestamp');
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 useQueryAlertsMock = useQueryAlerts as jest.Mock;
export const caseProps: CaseProps = {
caseId: basicCase.id,
userCanCrud: true,
caseData: {
...basicCase,
comments: [...basicCase.comments, alertComment],
connector: {
id: 'resilient-2',
name: 'Resilient',
type: ConnectorTypes.resilient,
fields: null,
},
},
fetchCase: jest.fn(),
updateCase: jest.fn(),
};
export const caseClosedProps: CaseProps = {
...caseProps,
caseData: basicCaseClosed,
};
const alertsHit = [
{
@ -103,6 +68,54 @@ 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(),
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: () => [
false,
{
'alert-id-1': alertsHit[0],
'alert-id-2': alertsHit[1],
},
],
userCanCrud: true,
caseData: {
...basicCase,
comments: [...basicCase.comments, alertComment],
connector: {
id: 'resilient-2',
name: 'Resilient',
type: ConnectorTypes.resilient,
fields: null,
},
},
fetchCase: jest.fn(),
updateCase: jest.fn(),
};
export const caseClosedProps: CaseComponentProps = {
...caseProps,
caseData: basicCaseClosed,
};
describe('CaseView ', () => {
const updateCaseProperty = jest.fn();
const fetchCaseUserActions = jest.fn();
@ -139,20 +152,14 @@ describe('CaseView ', () => {
};
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockImplementation(() => ({
isLoading: false,
pushCaseToExternalService,
}));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useQueryAlertsMock.mockImplementation(() => ({
loading: false,
data: { hits: { hits: alertsHit } },
}));
});
it('should render CaseComponent', async () => {
@ -168,44 +175,44 @@ describe('CaseView ', () => {
expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual(
data.title
);
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Open'
);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`)
.first()
.text()
).toEqual(data.tags[0]);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`)
.first()
.text()
).toEqual(data.tags[1]);
expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual(
data.createdBy.username
);
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(data.createdAt);
expect(
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
.first()
.text()
).toBe(data.description);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text()
).toBe('Mark in progress');
});
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Open'
);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`)
.first()
.text()
).toEqual(data.tags[0]);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`)
.first()
.text()
).toEqual(data.tags[1]);
expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual(
data.createdBy.username
);
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(data.createdAt);
expect(
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
.first()
.text()
).toBe(data.description);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text()
).toBe('Mark in progress');
});
it('should show closed indicators in header when case is closed', async () => {
@ -341,20 +348,17 @@ describe('CaseView ', () => {
</Router>
</TestProviders>
);
const newTitle = 'The new title';
wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click');
wrapper
.find(`[data-test-subj="editable-title-input-field"]`)
.last()
.simulate('change', { target: { value: newTitle } });
wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click');
const updateObject = updateCaseProperty.mock.calls[0][0];
await waitFor(() => {
const newTitle = 'The new title';
wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click');
wrapper.update();
wrapper
.find(`[data-test-subj="editable-title-input-field"]`)
.last()
.simulate('change', { target: { value: newTitle } });
wrapper.update();
wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click');
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('title');
expect(updateObject.updateValue).toEqual(newTitle);
});
@ -378,11 +382,10 @@ describe('CaseView ', () => {
expect(
wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists()
).toBeTruthy();
});
wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click');
wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click');
wrapper.update();
await waitFor(() => {
expect(pushCaseToExternalService).toHaveBeenCalled();
});
});
@ -397,7 +400,27 @@ describe('CaseView ', () => {
<Router history={mockHistory}>
<CaseView
{...{
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,
}}
/>
@ -419,7 +442,27 @@ describe('CaseView ', () => {
<Router history={mockHistory}>
<CaseView
{...{
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,
}}
/>
@ -438,7 +481,27 @@ describe('CaseView ', () => {
<Router history={mockHistory}>
<CaseView
{...{
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,
}}
/>
@ -457,15 +520,35 @@ describe('CaseView ', () => {
<Router history={mockHistory}>
<CaseView
{...{
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,
}}
/>
</Router>
</TestProviders>
);
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
await waitFor(() => {
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
expect(fetchCaseUserActions).toBeCalledWith('1234', 'resilient-2', undefined);
expect(fetchCase).toBeCalled();
});
@ -497,7 +580,7 @@ describe('CaseView ', () => {
});
});
// TO DO fix when the useEffects in edit_connector are cleaned up
// TODO: fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
onError();
@ -526,18 +609,13 @@ describe('CaseView ', () => {
.first()
.text();
await waitFor(() => {
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
});
await waitFor(() => {
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
});
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
@ -548,7 +626,6 @@ describe('CaseView ', () => {
).toBe(connectorName);
});
});
it('should update connector', async () => {
const wrapper = mount(
<TestProviders>
@ -572,14 +649,12 @@ describe('CaseView ', () => {
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
await waitFor(() => {
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
});
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click');
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
expect(updateObject.updateKey).toEqual('connector');
@ -595,34 +670,23 @@ describe('CaseView ', () => {
});
});
it('it should create a new timeline on mount', async () => {
it('it should call onComponentInitialized on mount', async () => {
const onComponentInitialized = jest.fn();
mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
<CaseComponent {...caseProps} onComponentInitialized={onComponentInitialized} />
</Router>
</TestProviders>
);
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith({
type: 'x-pack/security_solution/local/timeline/CREATE_TIMELINE',
payload: {
columns: [],
expandedDetail: {},
id: 'timeline-case',
indexNames: [],
show: false,
},
});
expect(onComponentInitialized).toHaveBeenCalled();
});
});
it('should show loading content when loading alerts', async () => {
useQueryAlertsMock.mockImplementation(() => ({
loading: true,
data: { hits: { hits: [] } },
}));
const useFetchAlertData = jest.fn().mockReturnValue([true]);
useGetCaseUserActionsMock.mockReturnValue({
caseServices: {},
caseUserActions: [],
@ -635,7 +699,7 @@ describe('CaseView ', () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
<CaseComponent {...caseProps} useFetchAlertData={useFetchAlertData} />
</Router>
</TestProviders>
);
@ -648,28 +712,22 @@ describe('CaseView ', () => {
});
});
it('should open the alert flyout', async () => {
it('should call show alert details with expected arguments', async () => {
const showAlertDetails = jest.fn();
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
<CaseComponent {...caseProps} showAlertDetails={showAlertDetails} />
</Router>
</TestProviders>
);
wrapper
.find('[data-test-subj="comment-action-show-alert-alert-action-id"] button')
.first()
.simulate('click');
await waitFor(() => {
wrapper
.find('[data-test-subj="comment-action-show-alert-alert-action-id"] button')
.first()
.simulate('click');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL',
payload: {
panelView: 'eventDetail',
params: { eventId: 'alert-id-1', indexName: 'alert-index-1' },
timelineId: 'timeline-case',
},
});
expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1');
});
});
@ -703,9 +761,8 @@ describe('CaseView ', () => {
</TestProviders>
);
wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click');
await waitFor(() => {
wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click');
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('settings');

View file

@ -0,0 +1,538 @@
/*
* 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, useEffect, useMemo, useState, useRef } from 'react';
// import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiLoadingSpinner,
EuiHorizontalRule,
} from '@elastic/eui';
import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector } from '../../../common';
import { HeaderPage } from '../header_page';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { useGetCase } from '../../containers/use_get_case';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { getTypedPayload } from '../../containers/utils';
import { WhitePageWrapper, HeaderWrapper } from '../wrappers';
import { CaseActionBar } from '../case_action_bar';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { usePushToService } from '../use_push_to_service';
import { EditConnector } from '../edit_connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import {
getConnectorById,
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { StatusActionButton } from '../status/button';
import * as i18n from './translations';
import { Ecs } from '../../../common';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { CasesNavigation } from '../links';
const gutterTimeline = '70px'; // seems to be a timeline reference from the original file
export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation;
caseId: string;
configureCasesNavigation: CasesNavigation;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
onComponentInitialized?: () => void;
ruleDetailsNavigation: CasesNavigation<string | null | undefined, 'configurable'>;
showAlertDetails: (alertId: string, index: string) => void;
subCaseId?: string;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
}
export interface CaseViewProps extends CaseViewComponentProps {
onCaseDataSuccess?: (data: Case) => void;
timelineIntegration?: CasesTimelineIntegration;
}
export interface OnUpdateFields {
key: keyof Case;
value: Case[keyof Case];
onSuccess?: () => void;
onError?: () => void;
}
const MyWrapper = styled.div`
padding: ${({ theme }) =>
`${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l}`};
`;
const MyEuiFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
const MyEuiHorizontalRule = styled(EuiHorizontalRule)`
margin-left: 48px;
&.euiHorizontalRule--full {
width: calc(100% - 48px);
}
`;
export interface CaseComponentProps extends CaseViewComponentProps {
fetchCase: () => void;
caseData: Case;
updateCase: (newCase: Case) => void;
}
export const CaseComponent = React.memo<CaseComponentProps>(
({
allCasesNavigation,
caseData,
caseDetailsNavigation,
caseId,
configureCasesNavigation,
getCaseDetailHrefWithCommentId,
fetchCase,
onComponentInitialized,
ruleDetailsNavigation,
showAlertDetails,
subCaseId,
updateCase,
useFetchAlertData,
userCanCrud,
}) => {
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
const timelineUi = useTimelineContext()?.ui;
const {
caseUserActions,
fetchCaseUserActions,
caseServices,
hasDataToPush,
isLoading: isLoadingUserActions,
participants,
} = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId);
const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({
caseId,
subCaseId,
});
// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
const handleUpdateNewCase = (newCase: Case) =>
updateCase({ ...newCase, comments: caseData.comments });
switch (key) {
case 'title':
const titleUpdate = getTypedPayload<string>(value);
if (titleUpdate.length > 0) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'title',
updateValue: titleUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'connector':
const connector = getTypedPayload<CaseConnector>(value);
if (connector != null) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'connector',
updateValue: connector,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'description':
const descriptionUpdate = getTypedPayload<string>(value);
if (descriptionUpdate.length > 0) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'description',
updateValue: descriptionUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'tags':
const tagsUpdate = getTypedPayload<string[]>(value);
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'tags',
updateValue: tagsUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
break;
case 'status':
const statusUpdate = getTypedPayload<CaseStatuses>(value);
if (caseData.status !== value) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'status',
updateValue: statusUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'settings':
const settingsUpdate = getTypedPayload<CaseAttributes['settings']>(value);
if (caseData.settings !== value) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'settings',
updateValue: settingsUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
default:
return null;
}
},
[fetchCaseUserActions, updateCaseProperty, updateCase, caseData]
);
const handleUpdateCase = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchCaseUserActions(caseId, newCase.connector.id, subCaseId);
},
[updateCase, fetchCaseUserActions, caseId, subCaseId]
);
const { loading: isLoadingConnectors, connectors } = useConnectors();
const [connectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connector.id);
return [connector?.name ?? '', !!connector];
}, [connectors, caseData.connector]);
const currentExternalIncident = useMemo(
() =>
caseServices != null && caseServices[caseData.connector.id] != null
? caseServices[caseData.connector.id]
: null,
[caseServices, caseData.connector]
);
const { pushButton, pushCallouts } = usePushToService({
configureCasesNavigation,
connector: {
...caseData.connector,
name: isEmpty(connectorName) ? caseData.connector.name : connectorName,
},
caseServices,
caseId: caseData.id,
caseStatus: caseData.status,
connectors,
updateCase: handleUpdateCase,
userCanCrud,
isValidConnector: isLoadingConnectors ? true : isValidConnector,
});
const onSubmitConnector = useCallback(
(connectorId, connectorFields, onError, onSuccess) => {
const connector = getConnectorById(connectorId, connectors);
const connectorToUpdate = connector
? normalizeActionConnector(connector)
: getNoneConnector();
onUpdateField({
key: 'connector',
value: { ...connectorToUpdate, fields: connectorFields },
onSuccess,
onError,
});
},
[onUpdateField, connectors]
);
const onSubmitTags = useCallback((newTags) => onUpdateField({ key: 'tags', value: newTags }), [
onUpdateField,
]);
const onSubmitTitle = useCallback(
(newTitle) => onUpdateField({ key: 'title', value: newTitle }),
[onUpdateField]
);
const changeStatus = useCallback(
(status: CaseStatuses) =>
onUpdateField({
key: 'status',
value: status,
}),
[onUpdateField]
);
const handleRefresh = useCallback(() => {
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId);
fetchCase();
}, [caseData.connector.id, caseId, fetchCase, fetchCaseUserActions, subCaseId]);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(caseDetailsNavigation.href),
}),
[caseDetailsNavigation.href, caseData.title]
);
useEffect(() => {
if (initLoadingData && !isLoadingUserActions) {
setInitLoadingData(false);
}
}, [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) => {
showAlertDetails(alertId, index);
},
[showAlertDetails]
);
// useEffect used for component's initialization
useEffect(() => {
if (init.current) {
init.current = false;
if (onComponentInitialized) {
onComponentInitialized();
}
}
}, [onComponentInitialized]);
return (
<>
<HeaderWrapper>
<HeaderPage
backOptions={backOptions}
data-test-subj="case-view-title"
titleNode={
<EditableTitle
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'title'}
title={caseData.title}
onSubmit={onSubmitTitle}
/>
}
title={caseData.title}
>
<CaseActionBar
currentExternalIncident={currentExternalIncident}
caseData={caseData}
disabled={!userCanCrud}
isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')}
onRefresh={handleRefresh}
onUpdateField={onUpdateField}
/>
</HeaderPage>
</HeaderWrapper>
<WhitePageWrapper>
<MyWrapper>
{!initLoadingData && pushCallouts != null && pushCallouts}
<EuiFlexGroup>
<EuiFlexItem grow={6}>
{initLoadingData && (
<EuiLoadingContent lines={8} data-test-subj="case-view-loading-content" />
)}
{!initLoadingData && (
<>
<UserActionTree
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
getRuleDetailsHref={ruleDetailsNavigation.href}
onRuleDetailsClick={ruleDetailsNavigation.onClick}
caseServices={caseServices}
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
fetchUserActions={fetchCaseUserActions.bind(
null,
caseId,
caseData.connector.id,
subCaseId
)}
isLoadingDescription={isLoading && updateKey === 'description'}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
renderInvestigateInTimelineActionComponent={
timelineUi?.renderInvestigateInTimelineActionComponent
}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
/>
{(caseData.type !== CaseType.collection || hasDataToPush) && (
<>
<MyEuiHorizontalRule
margin="s"
data-test-subj="case-view-bottom-actions-horizontal-rule"
/>
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
{caseData.type !== CaseType.collection && (
<EuiFlexItem grow={false}>
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
/>
</EuiFlexItem>
)}
{hasDataToPush && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
{pushButton}
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
)}
</>
)}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<UserList
data-test-subj="case-view-user-list-reporter"
email={emailContent}
headline={i18n.REPORTER}
users={[caseData.createdBy]}
/>
<UserList
data-test-subj="case-view-user-list-participants"
email={emailContent}
headline={i18n.PARTICIPANTS}
loading={isLoadingUserActions}
users={participants}
/>
<TagList
data-test-subj="case-view-tag-list"
disabled={!userCanCrud}
tags={caseData.tags}
onSubmit={onSubmitTags}
isLoading={isLoading && updateKey === 'tags'}
/>
<EditConnector
caseFields={caseData.connector.fields}
connectors={connectors}
disabled={!userCanCrud}
hideConnectorServiceNowSir={
subCaseId != null || caseData.type === CaseType.collection
}
isLoading={isLoadingConnectors || (isLoading && updateKey === 'connector')}
onSubmit={onSubmitConnector}
selectedConnector={caseData.connector.id}
userActions={caseUserActions}
/>
</EuiFlexItem>
</EuiFlexGroup>
</MyWrapper>
</WhitePageWrapper>
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
</>
);
}
);
export const CaseView = React.memo(
({
allCasesNavigation,
caseDetailsNavigation,
caseId,
configureCasesNavigation,
getCaseDetailHrefWithCommentId,
onCaseDataSuccess,
onComponentInitialized,
ruleDetailsNavigation,
showAlertDetails,
subCaseId,
timelineIntegration,
useFetchAlertData,
userCanCrud,
}: CaseViewProps) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
if (isError) {
return null;
}
if (isLoading) {
return (
<MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="case-view-loading" size="xl" />
</EuiFlexItem>
</MyEuiFlexGroup>
);
}
if (onCaseDataSuccess && data) {
onCaseDataSuccess(data);
}
return (
data && (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<CaseComponent
allCasesNavigation={allCasesNavigation}
caseData={data}
caseDetailsNavigation={caseDetailsNavigation}
caseId={caseId}
configureCasesNavigation={configureCasesNavigation}
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
fetchCase={fetchCase}
onComponentInitialized={onComponentInitialized}
ruleDetailsNavigation={ruleDetailsNavigation}
showAlertDetails={showAlertDetails}
subCaseId={subCaseId}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
/>
</CasesTimelineIntegrationProvider>
)
);
}
);
CaseComponent.displayName = 'CaseComponent';
CaseView.displayName = 'CaseView';
// eslint-disable-next-line import/no-default-export
export { CaseView as default };

View file

@ -0,0 +1,130 @@
/*
* 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 * from '../../common/translations';
export const SHOWING_CASES = (actionDate: string, actionName: string, userName: string) =>
i18n.translate('xpack.cases.caseView.actionHeadline', {
values: {
actionDate,
actionName,
userName,
},
defaultMessage: '{userName} {actionName} on {actionDate}',
});
export const ADDED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.addedField', {
defaultMessage: 'added',
});
export const CHANGED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.changededField', {
defaultMessage: 'changed',
});
export const SELECTED_THIRD_PARTY = (thirdParty: string) =>
i18n.translate('xpack.cases.caseView.actionLabel.selectedThirdParty', {
values: {
thirdParty,
},
defaultMessage: 'selected { thirdParty } as incident management system',
});
export const REMOVED_THIRD_PARTY = i18n.translate(
'xpack.cases.caseView.actionLabel.removedThirdParty',
{
defaultMessage: 'removed external incident management system',
}
);
export const EDITED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.editedField', {
defaultMessage: 'edited',
});
export const REMOVED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.removedField', {
defaultMessage: 'removed',
});
export const VIEW_INCIDENT = (incidentNumber: string) =>
i18n.translate('xpack.cases.caseView.actionLabel.viewIncident', {
defaultMessage: 'View {incidentNumber}',
values: {
incidentNumber,
},
});
export const PUSHED_NEW_INCIDENT = i18n.translate(
'xpack.cases.caseView.actionLabel.pushedNewIncident',
{
defaultMessage: 'pushed as new incident',
}
);
export const UPDATE_INCIDENT = i18n.translate('xpack.cases.caseView.actionLabel.updateIncident', {
defaultMessage: 'updated incident',
});
export const ADDED_DESCRIPTION = i18n.translate('xpack.cases.caseView.actionLabel.addDescription', {
defaultMessage: 'added description',
});
export const EDIT_DESCRIPTION = i18n.translate('xpack.cases.caseView.edit.description', {
defaultMessage: 'Edit description',
});
export const QUOTE = i18n.translate('xpack.cases.caseView.edit.quote', {
defaultMessage: 'Quote',
});
export const EDIT_COMMENT = i18n.translate('xpack.cases.caseView.edit.comment', {
defaultMessage: 'Edit comment',
});
export const ON = i18n.translate('xpack.cases.caseView.actionLabel.on', {
defaultMessage: 'on',
});
export const ADDED_COMMENT = i18n.translate('xpack.cases.caseView.actionLabel.addComment', {
defaultMessage: 'added comment',
});
export const STATUS = i18n.translate('xpack.cases.caseView.statusLabel', {
defaultMessage: 'Status',
});
export const CASE = i18n.translate('xpack.cases.caseView.case', {
defaultMessage: 'case',
});
export const COMMENT = i18n.translate('xpack.cases.caseView.comment', {
defaultMessage: 'comment',
});
export const CASE_REFRESH = i18n.translate('xpack.cases.caseView.caseRefresh', {
defaultMessage: 'Refresh case',
});
export const EMAIL_SUBJECT = (caseTitle: string) =>
i18n.translate('xpack.cases.caseView.emailSubject', {
values: { caseTitle },
defaultMessage: 'Security Case - {caseTitle}',
});
export const EMAIL_BODY = (caseUrl: string) =>
i18n.translate('xpack.cases.caseView.emailBody', {
values: { caseUrl },
defaultMessage: 'Case reference: {caseUrl}',
});
export const CHANGED_CONNECTOR_FIELD = i18n.translate('xpack.cases.caseView.fieldChanged', {
defaultMessage: `changed connector field`,
});
export const SYNC_ALERTS = i18n.translate('xpack.cases.caseView.syncAlertsLabel', {
defaultMessage: `Sync alerts`,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { ConnectorTypes } from '../../../../../../cases/common/api';
import { ConnectorTypes } from '../../../../common';
import { ActionConnector } from '../../../containers/configure/types';
import { UseConnectorsResponse } from '../../../containers/configure/use_connectors';
import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure';
@ -14,7 +14,6 @@ import { connectorsMock, actionTypesMock } from '../../../containers/configure/m
export { mappings } from '../../../containers/configure/mock';
export const connectors: ActionConnector[] = connectorsMock;
// x - pack / plugins / triggers_actions_ui;
export const searchURL =
'?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))';

View file

@ -9,10 +9,9 @@ import React from 'react';
import { ReactWrapper, mount } from 'enzyme';
import { EuiText } from '@elastic/eui';
import '../../../common/mock/match_media';
import '../../common/mock/match_media';
import { ConfigureCaseButton, ConfigureCaseButtonProps } from './button';
import { TestProviders } from '../../../common/mock';
import { searchURL } from './__mock__';
import { TestProviders } from '../../common/mock';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
@ -25,17 +24,18 @@ jest.mock('react-router-dom', () => {
};
});
jest.mock('../../../common/components/link_to');
describe('Configuration button', () => {
let wrapper: ReactWrapper;
const props: ConfigureCaseButtonProps = {
configureCasesNavigation: {
href: 'testHref',
onClick: jest.fn(),
},
isDisabled: false,
label: 'My label',
msgTooltip: <></>,
showToolTip: false,
titleTooltip: '',
urlSearch: searchURL,
};
beforeAll(() => {
@ -50,7 +50,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: `/configure`,
href: `testHref`,
iconType: 'controlsHorizontal',
isDisabled: false,
'aria-label': 'My label',

View file

@ -6,45 +6,33 @@
*/
import { EuiToolTip } from '@elastic/eui';
import React, { memo, useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import React, { memo, useMemo } from 'react';
import { CasesNavigation, LinkButton } from '../links';
import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/link_to';
import { LinkButton } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
// TODO: Potentially move into links component?
export interface ConfigureCaseButtonProps {
label: string;
configureCasesNavigation: CasesNavigation;
isDisabled: boolean;
label: string;
msgTooltip: JSX.Element;
showToolTip: boolean;
titleTooltip: string;
urlSearch: string;
}
const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
configureCasesNavigation: { href, onClick },
isDisabled,
label,
msgTooltip,
showToolTip,
titleTooltip,
urlSearch,
}: ConfigureCaseButtonProps) => {
const history = useHistory();
const { formatUrl } = useFormatUrl(SecurityPageName.case);
const goToCaseConfigure = useCallback(
(ev) => {
ev.preventDefault();
history.push(getConfigureCasesUrl(urlSearch));
},
[history, urlSearch]
);
const configureCaseButton = useMemo(
() => (
<LinkButton
onClick={goToCaseConfigure}
href={formatUrl(getConfigureCasesUrl())}
onClick={onClick}
href={href}
iconType="controlsHorizontal"
isDisabled={isDisabled}
aria-label={label}
@ -53,7 +41,7 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
{label}
</LinkButton>
),
[label, isDisabled, formatUrl, goToCaseConfigure]
[label, isDisabled, onClick, href]
);
return showToolTip ? (

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { ClosureOptions, ClosureOptionsProps } from './closure_options';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { ClosureOptionsRadio } from './closure_options_radio';
describe('ClosureOptions', () => {

View file

@ -9,7 +9,7 @@ import React from 'react';
import { ReactWrapper, mount } from 'enzyme';
import { ClosureOptionsRadio, ClosureOptionsRadioComponentProps } from './closure_options_radio';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
describe('ClosureOptionsRadio', () => {
let wrapper: ReactWrapper;

View file

@ -9,10 +9,10 @@ import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { Connectors, Props } from './connectors';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { ConnectorsDropdown } from './connectors_dropdown';
import { connectors } from './__mock__';
import { ConnectorTypes } from '../../../../../cases/common/api/connectors';
import { ConnectorTypes } from '../../../common';
describe('Connectors', () => {
let wrapper: ReactWrapper;

View file

@ -21,7 +21,7 @@ import * as i18n from './translations';
import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types';
import { Mapping } from './mapping';
import { ConnectorTypes } from '../../../../../cases/common/api/connectors';
import { ConnectorTypes } from '../../../common';
const EuiFormRowExtended = styled(EuiFormRow)`
.euiFormRow__labelWrapper {

View file

@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme';
import { EuiSuperSelect } from '@elastic/eui';
import { ConnectorsDropdown, Props } from './connectors_dropdown';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { connectors } from './__mock__';
describe('ConnectorsDropdown', () => {

View file

@ -9,7 +9,7 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import styled from 'styled-components';
import { ConnectorTypes } from '../../../../../cases/common/api';
import { ConnectorTypes } from '../../../common';
import { ActionConnector } from '../../containers/configure/types';
import { connectorsConfiguration } from '../connectors';
import * as i18n from './translations';

View file

@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme';
import { FieldMapping, FieldMappingProps } from './field_mapping';
import { mappings } from './__mock__';
import { TestProviders } from '../../../common/mock';
import { TestProviders } from '../../common/mock';
import { FieldMappingRowStatic } from './field_mapping_row_static';
describe('FieldMappingRow', () => {
@ -47,7 +47,7 @@ describe('FieldMappingRow', () => {
test('it pass the corrects props to mapping row', () => {
const rows = wrapper.find(FieldMappingRowStatic);
rows.forEach((row, index) => {
expect(row.prop('securitySolutionField')).toEqual(mappings[index].source);
expect(row.prop('casesField')).toEqual(mappings[index].source);
expect(row.prop('selectedActionType')).toEqual(mappings[index].actionType);
expect(row.prop('selectedThirdParty')).toEqual(mappings[index].target);
});

View file

@ -58,7 +58,7 @@ const FieldMappingComponent: React.FC<FieldMappingProps> = ({
{mappings.map((item) => (
<FieldMappingRowStatic
key={`${item.source}`}
securitySolutionField={item.source}
casesField={item.source}
isLoading={isLoading}
selectedActionType={item.actionType}
selectedThirdParty={item.target ?? 'not_mapped'}

View file

@ -13,14 +13,14 @@ import { CaseField, ActionType, ThirdPartyField } from '../../containers/configu
export interface RowProps {
isLoading: boolean;
securitySolutionField: CaseField;
casesField: CaseField;
selectedActionType: ActionType;
selectedThirdParty: ThirdPartyField;
}
const FieldMappingRowComponent: React.FC<RowProps> = ({
isLoading,
securitySolutionField,
casesField,
selectedActionType,
selectedThirdParty,
}) => {
@ -32,7 +32,7 @@ const FieldMappingRowComponent: React.FC<RowProps> = ({
<EuiFlexItem>
<EuiFlexGroup component="span" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem component="span" grow={false}>
<EuiCode data-test-subj="field-mapping-source">{securitySolutionField}</EuiCode>
<EuiCode data-test-subj="field-mapping-source">{casesField}</EuiCode>
</EuiFlexItem>
<EuiFlexItem component="span" grow={false}>
<EuiIcon type="sortRight" />

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