[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>
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -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 | SubCase) => void;</code> callback for row click, passing case in row
|
||||
|updateCase?|<code>(theCase: Case | 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 | null | 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
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
export * from './cases';
|
||||
export * from './connectors';
|
||||
export * from './helpers';
|
||||
export * from './runtime_types';
|
||||
export * from './saved_object';
|
||||
export * from './user';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
10
x-pack/plugins/cases/common/index.ts
Normal 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';
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from '../../translations';
|
||||
export * from './types';
|
|
@ -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;
|
||||
}
|
BIN
x-pack/plugins/cases/images/all_cases.png
Normal file
After Width: | Height: | Size: 255 KiB |
BIN
x-pack/plugins/cases/images/all_cases_selector_modal.png
Normal file
After Width: | Height: | Size: 378 KiB |
BIN
x-pack/plugins/cases/images/case_view.png
Normal file
After Width: | Height: | Size: 542 KiB |
BIN
x-pack/plugins/cases/images/configure.png
Normal file
After Width: | Height: | Size: 278 KiB |
BIN
x-pack/plugins/cases/images/create.png
Normal file
After Width: | Height: | Size: 294 KiB |
BIN
x-pack/plugins/cases/images/logo.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
x-pack/plugins/cases/images/recent_cases.png
Normal file
After Width: | Height: | Size: 49 KiB |
|
@ -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"
|
||||
}
|
||||
|
|
39
x-pack/plugins/cases/public/common/errors.ts
Normal 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);
|
|
@ -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();
|
132
x-pack/plugins/cases/public/common/lib/kibana/hooks.ts
Normal 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;
|
||||
};
|
10
x-pack/plugins/cases/public/common/lib/kibana/index.ts
Normal 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';
|
|
@ -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;
|
|
@ -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$ };
|
42
x-pack/plugins/cases/public/common/lib/kibana/services.ts
Normal 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?'
|
||||
);
|
||||
}
|
||||
}
|
8
x-pack/plugins/cases/public/common/mock/index.ts
Normal 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';
|
16
x-pack/plugins/cases/public/common/mock/match_media.ts
Normal 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(),
|
||||
};
|
||||
});
|
61
x-pack/plugins/cases/public/common/mock/test_providers.tsx
Normal 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,
|
||||
};
|
||||
};
|
33
x-pack/plugins/cases/public/common/shared_imports.ts
Normal 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';
|
12
x-pack/plugins/cases/public/common/test_utils.ts
Normal 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, '');
|
259
x-pack/plugins/cases/public/common/translations.ts
Normal 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',
|
||||
});
|
50
x-pack/plugins/cases/public/components/__mock__/form.ts
Normal 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;
|
40
x-pack/plugins/cases/public/components/__mock__/router.ts
Normal 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 };
|
33
x-pack/plugins/cases/public/components/__mock__/timeline.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
|
@ -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';
|
|
@ -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>> => {
|
|
@ -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';
|
|
@ -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';
|
|
@ -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 {
|
58
x-pack/plugins/cases/public/components/all_cases/count.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
};
|
||||
};
|
66
x-pack/plugins/cases/public/components/all_cases/header.tsx
Normal 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>
|
||||
);
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
23
x-pack/plugins/cases/public/components/all_cases/index.tsx
Normal 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 };
|
|
@ -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>
|
||||
);
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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,
|
|
@ -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>;
|
148
x-pack/plugins/cases/public/components/all_cases/table.tsx
Normal 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>
|
||||
);
|
|
@ -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';
|
|
@ -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(
|
|
@ -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',
|
||||
});
|
26
x-pack/plugins/cases/public/components/all_cases/types.ts
Normal 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;
|
||||
}
|
173
x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
@ -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',
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
54
x-pack/plugins/cases/public/components/callout/callout.tsx
Normal 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);
|
|
@ -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);
|
||||
});
|
||||
});
|
22
x-pack/plugins/cases/public/components/callout/helpers.tsx
Normal 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));
|
217
x-pack/plugins/cases/public/components/callout/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
103
x-pack/plugins/cases/public/components/callout/index.tsx
Normal 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);
|
|
@ -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',
|
||||
});
|
13
x-pack/plugins/cases/public/components/callout/types.ts
Normal 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';
|
||||
}
|
|
@ -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';
|
|
@ -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 },
|
||||
])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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();
|
|
@ -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';
|
|
@ -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', () => {
|
|
@ -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 {
|
|
@ -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);
|
|
@ -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;
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
22
x-pack/plugins/cases/public/components/case_view/helpers.ts
Normal 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];
|
||||
};
|
|
@ -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');
|
538
x-pack/plugins/cases/public/components/case_view/index.tsx
Normal 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 };
|
130
x-pack/plugins/cases/public/components/case_view/translations.ts
Normal 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`,
|
||||
});
|
|
@ -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)))';
|
||||
|
|
@ -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',
|
|
@ -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 ? (
|
|
@ -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', () => {
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 {
|
|
@ -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', () => {
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
|
@ -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'}
|
|
@ -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" />
|